Compare commits

...

42 Commits

Author SHA1 Message Date
Jeremy Stretch
8cf8710130 Merge pull request #2725 from digitalocean/develop
Release v2.5.2
2018-12-21 11:46:31 -05:00
Jeremy Stretch
3705e37678 Release v2.5.2 2018-12-21 11:44:30 -05:00
Jeremy Stretch
ebe5193348 Fixes #2724: Limit rear port choices to current device when editing a front port 2018-12-21 11:09:44 -05:00
Jeremy Stretch
a3097d254e Fixes #2721: Detect loops when tracing front/rear ports 2018-12-21 10:54:20 -05:00
Jeremy Stretch
38276d9539 Fixes #2723: Correct permission evaluation when bulk deleting tags 2018-12-21 09:11:07 -05:00
Jeremy Stretch
91a2168952 Fixes #2717: Fix bulk deletion of tags 2018-12-21 09:08:00 -05:00
Jeremy Stretch
4a10b4ece0 Fixes #2704: Fix form select widget population on parent with null value 2018-12-20 15:49:35 -05:00
Jeremy Stretch
853b1fad15 Fixes #2712: Preserve list filtering after editing objects in bulk 2018-12-20 15:33:53 -05:00
Jeremy Stretch
7acbeb55bc Minor tweaks 2018-12-20 09:54:59 -05:00
Jeremy Stretch
8498e0088b Merge pull request #2667 from Jemikwa/develop
#2656 Updating LDAP documentation
2018-12-20 09:53:21 -05:00
Jeremy Stretch
aae10f7d71 Tweaked 200GE and 400GE interface type labels 2018-12-20 09:18:18 -05:00
Jeremy Stretch
6b19a2b101 Fixes #2709: Update example report for compatibility with v2.5 2018-12-19 16:34:35 -05:00
Jeremy Stretch
b44a76e6bd Closes #2537: Added AUTH_LDAP_MIRROR_GROUPS setting to LDAP docs 2018-12-19 16:24:41 -05:00
Jeremy Stretch
7f71fc1d42 Closes #2561: Add 200G and 400G interface types 2018-12-19 16:13:04 -05:00
Jeremy Stretch
ba9fe408bc #2675: Added InventoryItem search form field for 'discovered' 2018-12-19 14:15:22 -05:00
Jeremy Stretch
40cb576e11 Fixes #2673: Fix exception on LLDP neighbors view for device with a circuit connected 2018-12-19 14:04:22 -05:00
Jeremy Stretch
2f1db2fdf3 Fixes #2691: Cable trace should follow circuits 2018-12-19 12:48:20 -05:00
Jeremy Stretch
f4a22e5af3 Introduced fgcolor template filter to render ideal foreground color for any background color 2018-12-19 12:17:40 -05:00
Jeremy Stretch
aca57ec281 Fixes #2698: Remove pagination restriction on bulk component creation for devices/VMs 2018-12-19 10:59:12 -05:00
Jeremy Stretch
68cb8b6895 Closes #2701: Enable filtering of prefixes by exact prefix value 2018-12-19 10:02:18 -05:00
Jeremy Stretch
82e8c0152e Fixes #2707: Correct permission evaluation for circuit termination cabling 2018-12-19 09:36:45 -05:00
Jeremy Stretch
d4a9318826 Post-release version bump 2018-12-13 15:24:13 -05:00
Jeremy Stretch
27a893a9a1 Merge pull request #2688 from digitalocean/develop
Release v2.5.1
2018-12-13 15:20:09 -05:00
Jeremy Stretch
9f1fcca5ea Release v2.5.1 2018-12-13 15:03:08 -05:00
Jeremy Stretch
bb564363d5 Fix regression from #2683 2018-12-13 14:59:54 -05:00
Jeremy Stretch
dd2a6a41da Fixes #2687: Correct naming of before/after filters for changelog entries 2018-12-13 14:43:05 -05:00
Jeremy Stretch
a6c8c615eb Closes #2674: Enable filtering changelog by object type under web UI 2018-12-13 14:37:03 -05:00
Jeremy Stretch
0d3b1bfca4 Fixes #2683: Fix exception when connecting a cable to a RearPort with no corresponding FrontPort 2018-12-12 16:40:34 -05:00
Jeremy Stretch
edd763b1aa Fixes #2684: Fix custom field filtering 2018-12-12 16:06:50 -05:00
Jeremy Stretch
2418fed65b Fixes #2663: Prevent duplicate interfaces from appearing under VLAN members view 2018-12-12 13:18:42 -05:00
Jeremy Stretch
785cdcefd6 Closes #2671: Add documentation of API brief format 2018-12-12 10:27:18 -05:00
Jeremy Stretch
3480832bf5 Added changelog for #2662 (fixed under #2680) 2018-12-12 09:59:54 -05:00
Jeremy Stretch
ee038bd77b Closes #2655: Add 128GFC Fibrechannel interface type 2018-12-12 09:48:17 -05:00
Jeremy Stretch
6460c95e00 Fixes #2678: Fix error when viewing webhook in admin UI without write permission 2018-12-12 09:30:31 -05:00
Jeremy Stretch
b0a6781623 Fixes #2680: Disallow POST requests to /dcim/interface-connections/ API endpoint 2018-12-12 09:20:07 -05:00
Jeremy Stretch
8364e56e86 Fixes #2676: Fix exception when passing dictionary value to a ChoiceField 2018-12-11 17:00:20 -05:00
Jeremy Stretch
b8a4316297 Changelog for #2666 2018-12-11 13:47:24 -05:00
Jeremy Stretch
24d1707693 Merge pull request #2670 from DanSheps/2666-fix-length-display
Fixes #2666: Uses correct function for displaying choices label
2018-12-11 13:45:16 -05:00
dansheps
b4f79f1667 Fixes #2666: Uses correct function for displaying choices label
* Changes record.length_type to record.get_length_type_display
2018-12-11 12:40:07 -06:00
Jemikwa
064dd9bef2 Updating LDAP documentation
Adding information on service restarts and logging LDAP queries for troubleshooting.
2018-12-11 11:45:45 -06:00
Jeremy Stretch
b697c30941 #2627: Removed reference to provider from Circuit.__str__() 2018-12-11 11:15:45 -05:00
Jeremy Stretch
93c95fdfa8 Post-release version bump 2018-12-10 10:29:51 -05:00
35 changed files with 309 additions and 65 deletions

View File

@@ -1,3 +1,46 @@
v2.5.2 (2018-12-21)
## Enhancements
* [#2561](https://github.com/digitalocean/netbox/issues/2561) - Add 200G and 400G interface types
* [#2701](https://github.com/digitalocean/netbox/issues/2701) - Enable filtering of prefixes by exact prefix value
## Bug Fixes
* [#2673](https://github.com/digitalocean/netbox/issues/2673) - Fix exception on LLDP neighbors view for device with a circuit connected
* [#2691](https://github.com/digitalocean/netbox/issues/2691) - Cable trace should follow circuits
* [#2698](https://github.com/digitalocean/netbox/issues/2698) - Remove pagination restriction on bulk component creation for devices/VMs
* [#2704](https://github.com/digitalocean/netbox/issues/2704) - Fix form select widget population on parent with null value
* [#2707](https://github.com/digitalocean/netbox/issues/2707) - Correct permission evaluation for circuit termination cabling
* [#2712](https://github.com/digitalocean/netbox/issues/2712) - Preserve list filtering after editing objects in bulk
* [#2717](https://github.com/digitalocean/netbox/issues/2717) - Fix bulk deletion of tags
* [#2721](https://github.com/digitalocean/netbox/issues/2721) - Detect loops when tracing front/rear ports
* [#2723](https://github.com/digitalocean/netbox/issues/2723) - Correct permission evaluation when bulk deleting tags
* [#2724](https://github.com/digitalocean/netbox/issues/2724) - Limit rear port choices to current device when editing a front port
---
v2.5.1 (2018-12-13)
## Enhancements
* [#2655](https://github.com/digitalocean/netbox/issues/2655) - Add 128GFC Fibrechannel interface type
* [#2674](https://github.com/digitalocean/netbox/issues/2674) - Enable filtering changelog by object type under web UI
## Bug Fixes
* [#2662](https://github.com/digitalocean/netbox/issues/2662) - Fix ImproperlyConfigured exception when rendering API docs
* [#2663](https://github.com/digitalocean/netbox/issues/2663) - Prevent duplicate interfaces from appearing under VLAN members view
* [#2666](https://github.com/digitalocean/netbox/issues/2666) - Correct display of length unit in cables list
* [#2676](https://github.com/digitalocean/netbox/issues/2676) - Fix exception when passing dictionary value to a ChoiceField
* [#2678](https://github.com/digitalocean/netbox/issues/2678) - Fix error when viewing webhook in admin UI without write permission
* [#2680](https://github.com/digitalocean/netbox/issues/2680) - Disallow POST requests to `/dcim/interface-connections/` API endpoint
* [#2683](https://github.com/digitalocean/netbox/issues/2683) - Fix exception when connecting a cable to a RearPort with no corresponding FrontPort
* [#2684](https://github.com/digitalocean/netbox/issues/2684) - Fix custom field filtering
* [#2687](https://github.com/digitalocean/netbox/issues/2687) - Correct naming of before/after filters for changelog entries
---
v2.5.0 (2018-12-10)
## Notes

View File

@@ -44,7 +44,7 @@ class DeviceConnectionsReport(Report):
# Check that every console port for every active device has a connection defined.
for console_port in ConsolePort.objects.select_related('device').filter(device__status=DEVICE_STATUS_ACTIVE):
if console_port.cs_port is None:
if console_port.connected_endpoint is None:
self.log_failure(
console_port.device,
"No console connection defined for {}".format(console_port.name)
@@ -63,7 +63,7 @@ class DeviceConnectionsReport(Report):
for device in Device.objects.filter(status=DEVICE_STATUS_ACTIVE):
connected_ports = 0
for power_port in PowerPort.objects.filter(device=device):
if power_port.power_outlet is not None:
if power_port.connected_endpoint is not None:
connected_ports += 1
if power_port.connection_status == CONNECTION_STATUS_PLANNED:
self.log_warning(

View File

@@ -104,7 +104,7 @@ The base serializer is used to represent the default view of a model. This inclu
}
```
Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model.
Related objects (e.g. `ForeignKey` fields) are represented using a nested serializer. A nested serializer provides a minimal representation of an object, including only its URL and enough information to construct its name. When performing write api actions (`POST`, `PUT`, and `PATCH`), any `ForeignKey` relationships do not use the nested serializer, instead you will pass just the integer ID of the related model.
When a base serializer includes one or more nested serializers, the hierarchical structure precludes it from being used for write operations. Thus, a flat representation of an object may be provided using a writable serializer. This serializer includes only raw database values and is not typically used for retrieval, except as part of the response to the creation or updating of an object.
@@ -122,6 +122,52 @@ When a base serializer includes one or more nested serializers, the hierarchical
}
```
## Brief Format
Most API endpoints support an optional "brief" format, which returns only a minimal representation of each object in the response. This is useful when you need only a list of the objects themselves without any related data, such as when populating a drop-down list in a form.
For example, the default (complete) format of an IP address looks like this:
```
GET /api/ipam/prefixes/13980/
{
"id": 13980,
"family": 4,
"prefix": "192.0.2.0/24",
"site": null,
"vrf": null,
"tenant": null,
"vlan": null,
"status": {
"value": 1,
"label": "Active"
},
"role": null,
"is_pool": false,
"description": "",
"tags": [],
"custom_fields": {},
"created": "2018-12-11",
"last_updated": "2018-12-11T16:27:55.073174-05:00"
}
```
The brief format is much more terse, but includes a link to the object's full representation:
```
GET /api/ipam/prefixes/13980/?brief=1
{
"id": 13980,
"url": "https://netbox/api/ipam/prefixes/13980/",
"family": 4,
"prefix": "192.0.2.0/24"
}
```
The brief format is supported for both lists and individual objects.
## Static Choice Fields
Some model fields, such as the `status` field in the above example, utilize static integers corresponding to static choices. The available choices can be retrieved from the read-only `_choices` endpoint within each app. A specific `model:field` tuple may optionally be specified in the URL.

View File

@@ -95,6 +95,9 @@ AUTH_LDAP_GROUP_TYPE = GroupOfNamesType()
# Define a group required to login.
AUTH_LDAP_REQUIRE_GROUP = "CN=NETBOX_USERS,DC=example,DC=com"
# Mirror LDAP group assignments.
AUTH_LDAP_MIRROR_GROUPS = True
# Define special user types using groups. Exercise great caution when assigning superuser status.
AUTH_LDAP_USER_FLAGS_BY_GROUP = {
"is_active": "cn=active,ou=groups,dc=example,dc=com",
@@ -113,3 +116,21 @@ AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600
* `is_active` - All users must be mapped to at least this group to enable authentication. Without this, users cannot log in.
* `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.
# 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/`.
For troubleshooting LDAP user/group queries, add the following lines to the start of `ldap_config.py` after `import ldap`.
```python
import logging, logging.handlers
logfile = "/opt/netbox/logs/django-ldap-debug.log"
my_logger = logging.getLogger('django_auth_ldap')
my_logger.setLevel(logging.DEBUG)
handler = logging.handlers.RotatingFileHandler(
logfile, maxBytes=1024 * 500, backupCount=5)
my_logger.addHandler(handler)
```
Ensure the file and path specified in logfile exist and are writable and executable by the application service account. Restart the netbox service and attempt to log into the site to trigger log entries to this file.

View File

@@ -176,7 +176,7 @@ class Circuit(ChangeLoggedModel, CustomFieldModel):
unique_together = ['provider', 'cid']
def __str__(self):
return '{} {}'.format(self.provider, self.cid)
return self.cid
def get_absolute_url(self):
return reverse('circuits:circuit', args=[self.pk])

View File

@@ -60,7 +60,7 @@ class CableTraceMixin(object):
# Initialize the path array
path = []
for near_end, cable, far_end in obj.trace():
for near_end, cable, far_end in obj.trace(follow_circuits=True):
# Serialize each object
serializer_a = get_serializer_for_model(near_end, prefix='Nested')
@@ -484,7 +484,7 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
filterset_class = filters.PowerConnectionFilter
class InterfaceConnectionViewSet(ModelViewSet):
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
queryset = Interface.objects.select_related(
'device', '_connected_interface', '_connected_circuittermination'
).filter(

View File

@@ -82,6 +82,9 @@ IFACE_FF_100GE_CFP2 = 1510
IFACE_FF_100GE_CFP4 = 1520
IFACE_FF_100GE_CPAK = 1550
IFACE_FF_100GE_QSFP28 = 1600
IFACE_FF_200GE_CFP2 = 1650
IFACE_FF_200GE_QSFP56 = 1700
IFACE_FF_400GE_QSFP_DD = 1750
# Wireless
IFACE_FF_80211A = 2600
IFACE_FF_80211G = 2610
@@ -103,6 +106,7 @@ IFACE_FF_4GFC_SFP = 3040
IFACE_FF_8GFC_SFP_PLUS = 3080
IFACE_FF_16GFC_SFP_PLUS = 3160
IFACE_FF_32GFC_SFP28 = 3320
IFACE_FF_128GFC_QSFP28 = 3400
# Serial
IFACE_FF_T1 = 4000
IFACE_FF_E1 = 4010
@@ -152,9 +156,12 @@ IFACE_FF_CHOICES = [
[IFACE_FF_40GE_QSFP_PLUS, 'QSFP+ (40GE)'],
[IFACE_FF_100GE_CFP, 'CFP (100GE)'],
[IFACE_FF_100GE_CFP2, 'CFP2 (100GE)'],
[IFACE_FF_200GE_CFP2, 'CFP2 (200GE)'],
[IFACE_FF_100GE_CFP4, 'CFP4 (100GE)'],
[IFACE_FF_100GE_CPAK, 'Cisco CPAK (100GE)'],
[IFACE_FF_100GE_QSFP28, 'QSFP28 (100GE)'],
[IFACE_FF_200GE_QSFP56, 'QSFP56 (200GE)'],
[IFACE_FF_400GE_QSFP_DD, 'QSFP-DD (400GE)'],
]
],
[
@@ -188,6 +195,7 @@ IFACE_FF_CHOICES = [
[IFACE_FF_8GFC_SFP_PLUS, 'SFP+ (8GFC)'],
[IFACE_FF_16GFC_SFP_PLUS, 'SFP+ (16GFC)'],
[IFACE_FF_32GFC_SFP28, 'SFP28 (32GFC)'],
[IFACE_FF_128GFC_QSFP28, 'QSFP28 (128GFC)'],
]
],
[

View File

@@ -0,0 +1,5 @@
class LoopDetected(Exception):
"""
A loop has been detected while tracing a cable path.
"""
pass

View File

@@ -2098,6 +2098,15 @@ class FrontPortForm(BootstrapMixin, forms.ModelForm):
'device': forms.HiddenInput(),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Limit RearPort choices to the local device
if hasattr(self.instance, 'device'):
self.fields['rear_port'].queryset = self.fields['rear_port'].queryset.filter(
device=self.instance.device
)
# TODO: Merge with FrontPortTemplateCreateForm to remove duplicate logic
class FrontPortCreateForm(ComponentForm):
@@ -2703,6 +2712,12 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form):
to_field_name='slug',
null_label='-- None --'
)
discovered = forms.NullBooleanField(
required=False,
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
#

View File

@@ -21,6 +21,7 @@ from utilities.managers import NaturalOrderingManager
from utilities.models import ChangeLoggedModel
from utilities.utils import serialize_object, to_meters
from .constants import *
from .exceptions import LoopDetected
from .fields import ASNField, MACAddressField
from .managers import DeviceComponentManager, InterfaceManager
@@ -88,7 +89,7 @@ class CableTermination(models.Model):
class Meta:
abstract = True
def trace(self, position=1, follow_circuits=False):
def trace(self, position=1, follow_circuits=False, cable_history=None):
"""
Return a list representing a complete cable path, with each individual segment represented as a three-tuple:
[
@@ -110,11 +111,14 @@ class CableTermination(models.Model):
raise Exception("Invalid position for {} ({} positions): {})".format(
termination, termination.positions, position
))
peer_port = FrontPort.objects.get(
rear_port=termination,
rear_port_position=position,
)
return peer_port, 1
try:
peer_port = FrontPort.objects.get(
rear_port=termination,
rear_port_position=position,
)
return peer_port, 1
except ObjectDoesNotExist:
return None, None
# Follow a circuit to its other termination
elif isinstance(termination, CircuitTermination) and follow_circuits:
@@ -130,6 +134,13 @@ class CableTermination(models.Model):
if not self.cable:
return [(self, None, None)]
# Record cable history to detect loops
if cable_history is None:
cable_history = []
elif self.cable in cable_history:
raise LoopDetected()
cable_history.append(self.cable)
far_end = self.cable.termination_b if self.cable.termination_a == self else self.cable.termination_a
path = [(self, self.cable, far_end)]
@@ -137,7 +148,11 @@ class CableTermination(models.Model):
if peer_port is None:
return path
next_segment = peer_port.trace(position)
try:
next_segment = peer_port.trace(position, follow_circuits, cable_history)
except LoopDetected:
return path
if next_segment is None:
return path + [(peer_port, None, None)]
@@ -2629,5 +2644,7 @@ class Cable(ChangeLoggedModel):
path_status = CONNECTION_STATUS_PLANNED
break
# (A path end, B path end, connected/planned)
return a_path[-1][2], b_path[-1][2], path_status
a_endpoint = a_path[-1][2]
b_endpoint = b_path[-1][2]
return a_endpoint, b_endpoint, path_status

View File

@@ -62,7 +62,7 @@ def nullify_connected_endpoints(instance, **kwargs):
instance.termination_b.save()
# If this Cable was part of a complete path, tear it down
if endpoint_a is not None and endpoint_b is not None:
if hasattr(endpoint_a, 'connected_endpoint') and hasattr(endpoint_b, 'connected_endpoint'):
endpoint_a.connected_endpoint = None
endpoint_a.connection_status = None
endpoint_a.save()

View File

@@ -29,7 +29,8 @@ SITE_REGION_LINK = """
"""
COLOR_LABEL = """
<label class="label" style="background-color: #{{ record.color }}">{{ record }}</label>
{% load helpers %}
<label class="label" style="color: {{ record.color|fgcolor }}; background-color: #{{ record.color }}">{{ record }}</label>
"""
DEVICE_LINK = """
@@ -179,7 +180,7 @@ CABLE_TERMINATION_PARENT = """
"""
CABLE_LENGTH = """
{% if record.length %}{{ record.length }}{{ record.length_unit }}{% else %}&mdash;{% endif %}
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}&mdash;{% endif %}
"""

View File

@@ -1530,6 +1530,7 @@ class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateV
form = forms.DeviceBulkAddComponentForm
model = ConsolePort
model_form = forms.ConsolePortForm
filter = filters.DeviceFilter
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
@@ -1541,6 +1542,7 @@ class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentC
form = forms.DeviceBulkAddComponentForm
model = ConsoleServerPort
model_form = forms.ConsoleServerPortForm
filter = filters.DeviceFilter
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
@@ -1552,6 +1554,7 @@ class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateVie
form = forms.DeviceBulkAddComponentForm
model = PowerPort
model_form = forms.PowerPortForm
filter = filters.DeviceFilter
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
@@ -1563,6 +1566,7 @@ class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateV
form = forms.DeviceBulkAddComponentForm
model = PowerOutlet
model_form = forms.PowerOutletForm
filter = filters.DeviceFilter
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
@@ -1574,6 +1578,7 @@ class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateVie
form = forms.DeviceBulkAddInterfaceForm
model = Interface
model_form = forms.InterfaceForm
filter = filters.DeviceFilter
table = tables.DeviceTable
default_return_url = 'dcim:device_list'
@@ -1585,6 +1590,7 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie
form = forms.DeviceBulkAddComponentForm
model = DeviceBay
model_form = forms.DeviceBayForm
filter = filters.DeviceFilter
table = tables.DeviceTable
default_return_url = 'dcim:device_list'

View File

@@ -30,7 +30,8 @@ class WebhookForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
order_content_types(self.fields['obj_type'])
if 'obj_type' in self.fields:
order_content_types(self.fields['obj_type'])
@admin.register(Webhook, site=admin_site)

View File

@@ -31,12 +31,12 @@ class CustomFieldFilter(django_filters.Filter):
# Treat 0 as None
if int(value) == 0:
return queryset.exclude(
custom_field_values__field__name=self.name,
custom_field_values__field__name=self.field_name,
)
# Match on exact CustomFieldChoice PK
else:
return queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__field__name=self.field_name,
custom_field_values__serialized_value=value,
)
except ValueError:
@@ -45,12 +45,12 @@ class CustomFieldFilter(django_filters.Filter):
# Apply the assigned filter logic (exact or loose)
if self.cf_type == CF_TYPE_BOOLEAN or self.filter_logic == CF_FILTER_EXACT:
queryset = queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__field__name=self.field_name,
custom_field_values__serialized_value=value
)
else:
queryset = queryset.filter(
custom_field_values__field__name=self.name,
custom_field_values__field__name=self.field_name,
custom_field_values__serialized_value__icontains=value
)

View File

@@ -11,7 +11,7 @@ from taggit.models import Tag
from dcim.models import DeviceRole, Platform, Region, Site
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, FilterChoiceField,
add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ContentTypeSelect, FilterChoiceField,
FilterTreeNodeMultipleChoiceField, LaxURLField, JSONField, SlugField,
)
from .constants import (
@@ -307,21 +307,20 @@ class ImageAttachmentForm(BootstrapMixin, forms.ModelForm):
# Change logging
#
class ObjectChangeFilterForm(BootstrapMixin, CustomFieldFilterForm):
class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
model = ObjectChange
q = forms.CharField(
required=False,
label='Search'
)
# TODO: Change time_0 and time_1 to time_after and time_before for django-filter==2.0
time_0 = forms.DateTimeField(
time_after = forms.DateTimeField(
label='After',
required=False,
widget=forms.TextInput(
attrs={'placeholder': 'YYYY-MM-DD hh:mm:ss'}
)
)
time_1 = forms.DateTimeField(
time_before = forms.DateTimeField(
label='Before',
required=False,
widget=forms.TextInput(
@@ -336,3 +335,9 @@ class ObjectChangeFilterForm(BootstrapMixin, CustomFieldFilterForm):
queryset=User.objects.order_by('username'),
required=False
)
changed_object_type = forms.ModelChoiceField(
queryset=ContentType.objects.order_by('model'),
required=False,
widget=ContentTypeSelect(),
label='Object Type'
)

View File

@@ -7,19 +7,19 @@ urlpatterns = [
# Tags
url(r'^tags/$', views.TagListView.as_view(), name='tag_list'),
url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
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-]+)/delete/$', views.TagDeleteView.as_view(), name='tag_delete'),
url(r'^tags/delete/$', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
# Config contexts
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/edit/$', views.ConfigContextBulkEditView.as_view(), name='configcontext_bulk_edit'),
url(r'^config-contexts/delete/$', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
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+)/delete/$', views.ConfigContextDeleteView.as_view(), name='configcontext_delete'),
url(r'^config-contexts/delete/$', views.ConfigContextBulkDeleteView.as_view(), name='configcontext_bulk_delete'),
# Image attachments
url(r'^image-attachments/(?P<pk>\d+)/edit/$', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),

View File

@@ -82,7 +82,7 @@ class TagDeleteView(PermissionRequiredMixin, ObjectDeleteView):
class TagBulkDeleteView(PermissionRequiredMixin, BulkDeleteView):
permission_required = 'circuits.delete_circuittype'
permission_required = 'taggit.delete_tag'
queryset = Tag.objects.annotate(
items=Count('taggit_taggeditem_items')
).order_by(

View File

@@ -112,6 +112,10 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
method='search',
label='Search',
)
prefix = django_filters.CharFilter(
method='filter_prefix',
label='Prefix',
)
within = django_filters.CharFilter(
method='search_within',
label='Within prefix',
@@ -197,6 +201,15 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet):
pass
return queryset.filter(qs_filter)
def filter_prefix(self, queryset, name, value):
if not value.strip():
return queryset
try:
query = str(netaddr.IPNetwork(value).cidr)
return queryset.filter(prefix=query)
except ValidationError:
return queryset.none()
def search_within(self, queryset, name, value):
value = value.strip()
if not value:

View File

@@ -812,7 +812,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
return Interface.objects.filter(
Q(untagged_vlan_id=self.pk) |
Q(tagged_vlans=self.pk)
)
).distinct()
class Service(ChangeLoggedModel, CustomFieldModel):

View File

@@ -430,7 +430,7 @@ class VLANDetailTable(VLANTable):
class VLANMemberTable(BaseTable):
parent = tables.LinkColumn(order_by=['device', 'virtual_machine'])
name = tables.Column(verbose_name='Interface')
name = tables.LinkColumn(verbose_name='Interface')
untagged = tables.TemplateColumn(
template_code=VLAN_MEMBER_UNTAGGED,
orderable=False

View File

@@ -22,7 +22,7 @@ except ImportError:
)
VERSION = '2.5.0'
VERSION = '2.5.2'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

View File

@@ -100,7 +100,7 @@ $(document).ready(function() {
} else if (filter_field.val()) {
rendered_url = rendered_url.replace(match[0], filter_field.val());
} else if (filter_field.attr('nullable') == 'true') {
rendered_url = rendered_url.replace(match[0], '0');
rendered_url = rendered_url.replace(match[0], 'null');
}
}

View File

@@ -10,7 +10,7 @@
{% endfor %}
<div class="row">
<div class="col-md-6 col-md-offset-3">
<h3>{% block title %}Circuit {{ obj.circuit }} - {{ form.term_side.value }} Side{% endblock %}</h3>
<h3>{% block title %}{{ obj.circuit.provider }} {{ obj.circuit }} - Side {{ form.term_side.value }}{% endblock %}</h3>
{% if form.non_field_errors %}
<div class="panel panel-danger">
<div class="panel-heading"><strong>Errors</strong></div>

View File

@@ -53,7 +53,7 @@
<i class="fa fa-angle-right"></i> {{ termination.connected_endpoint }}
{% endif %}
{% else %}
{% if perms.circuits.add_cable %}
{% if perms.dcim.add_cable %}
<div class="pull-right">
<a href="{% url 'circuits:circuittermination_connect' termination_a_id=termination.pk %}?return_url={{ circuit.get_absolute_url }}" class="btn btn-success btn-xs" title="Connect">
<i class="glyphicon glyphicon-resize-small" aria-hidden="true"></i> Connect

View File

@@ -22,13 +22,20 @@
{% for iface in interfaces %}
<tr id="{{ iface.name }}">
<td>{{ iface }}</td>
{% if iface.connected_endpoint %}
{% if iface.connected_endpoint.device %}
<td class="configured_device" data="{{ iface.connected_endpoint.device }}">
<a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">{{ iface.connected_endpoint.device }}</a>
</td>
<td class="configured_interface" data="{{ iface.connected_endpoint }}">
<span title="{{ iface.connected_endpoint.get_form_factor_display }}">{{ iface.connected_endpoint }}</span>
</td>
{% elif iface.connected_endpoint.circuit %}
{% with circuit=iface.connected_endpoint.circuit %}
<td colspan="2">
<i class="fa fa-fw fa-globe" title="Circuit"></i>
<a href="{{ circuit.get_absolute_url }}">{{ circuit.provider }} {{ circuit }}</a>
</td>
{% endwith %}
{% else %}
<td colspan="2">None</td>
{% endif %}

View File

@@ -29,7 +29,7 @@
<tr>
<td>Circuit</td>
<td>
<a href="{{ termination.circuit.get_absolute_url }}">{{ termination.circuit }}</a> (Side {{ termination.term_side }})
<a href="{{ termination.circuit.get_absolute_url }}">{{ termination.circuit }}</a> ({{ termination }})
</td>
</tr>
{% endif %}

View File

@@ -5,7 +5,7 @@
{% if end.device %}
<strong><a href="{{ end.device.get_absolute_url }}">{{ end.device }}</a></strong>
{% else %}
<strong><a href="{{ end.circuit.get_absolute_url }}">{{ end.circuit }}</a></strong>
<strong><a href="{{ end.circuit.provider.get_absolute_url }}">{{ end.circuit.provider }}</a></strong>
{% endif %}
</div>
<div class="panel-body text-center">
@@ -21,7 +21,8 @@
{% endwith %}
{% else %}
{# Circuit termination #}
<strong>Side {{ end.term_side }}</strong>
<strong><a href="{{ end.circuit.get_absolute_url }}">{{ end.circuit }}</a></strong><br/>
{{ end }}
{% endif %}
</div>
</div>

View File

@@ -75,10 +75,16 @@
{% elif iface.connected_endpoint.name %}
{# Connected to an Interface #}
<td>
<a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">{{ iface.connected_endpoint.device }}</a>
<a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">
{{ iface.connected_endpoint.device }}
</a>
</td>
<td>
<a href="{% url 'dcim:interface' pk=iface.connected_endpoint.pk %}"><span title="{{ iface.connected_endpoint.get_form_factor_display }}">{{ iface.connected_endpoint }}</span></a>
<a href="{% url 'dcim:interface' pk=iface.connected_endpoint.pk %}">
<span title="{{ iface.connected_endpoint.get_form_factor_display }}">
{{ iface.connected_endpoint }}
</span>
</a>
</td>
{% elif iface.connected_endpoint.term_side %}
{# Connected to a CircuitTermination #}
@@ -86,22 +92,38 @@
{% if peer_termination %}
{% if peer_termination.connected_endpoint %}
<td>
<a href="{% url 'dcim:device' pk=peer_termination.connected_endpoint.device.pk %}">{{ peer_termination.connected_endpoint.device }}</a><br/>
<small>via <i class="fa fa-fw fa-globe" title="Circuit"></i> <a href="{% url 'circuits:circuit' pk=iface.connected_endpoint.circuit_id %}">{{ iface.connected_endpoint.circuit }}</a></small>
<a href="{% url 'dcim:device' pk=peer_termination.connected_endpoint.device.pk %}">
{{ peer_termination.connected_endpoint.device }}
</a><br/>
<small>via <i class="fa fa-fw fa-globe" title="Circuit"></i>
<a href="{{ iface.connected_endpoint.circuit.get_absolure_url }}">
{{ iface.connected_endpoint.circuit.provider }}
{{ iface.connected_endpoint.circuit }}
</a>
</small>
</td>
<td>
{{ peer_termination.connected_endpoint }}
</td>
{% else %}
<td colspan="2">
<a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">{{ peer_termination.site }}</a>
via <i class="fa fa-fw fa-globe" title="Circuit"></i> <a href="{% url 'circuits:circuit' pk=iface.connected_endpoint.circuit_id %}">{{ iface.connected_endpoint.circuit }}</a>
<a href="{% url 'dcim:site' slug=peer_termination.site.slug %}">
{{ peer_termination.site }}
</a>
via <i class="fa fa-fw fa-globe" title="Circuit"></i>
<a href="{{ iface.connected_endpoint.circuit.get_absolute_url }}">
{{ iface.connected_endpoint.circuit.provider }}
{{ iface.connected_endpoint.circuit }}
</a>
</td>
{% endif %}
{% else %}
<td colspan="2">
<i class="fa fa-fw fa-globe" title="Circuit"></i>
<a href="{% url 'circuits:circuit' pk=iface.connected_endpoint.circuit_id %}">{{ iface.connected_endpoint.circuit }}</a>
<a href="{{ iface.connected_endpoint.circuit.get_absolute_url }}">
{{ iface.connected_endpoint.circuit.provider }}
{{ iface.connected_endpoint.circuit }}
</a>
</td>
{% endif %}
{% endwith %}

View File

@@ -163,6 +163,10 @@
</tr>
{% elif connected_circuittermination %}
{% with ct=connected_circuittermination %}
<tr>
<td>Provider</td>
<td><a href="{{ ct.circuit.provider.get_absolute_url }}">{{ ct.circuit.provider }}</a></td>
</tr>
<tr>
<td>Circuit</td>
<td><a href="{{ ct.circuit.get_absolute_url }}">{{ ct.circuit }}</a></td>

View File

@@ -2,7 +2,8 @@
{% load form_helpers %}
{% block content %}
<h1>Add {{ component_name|title }}</h1>
<h1>{% block title %}Add {{ model_name|title }}{% endblock %}</h1>
<p>{{ table.rows|length }} {{ parent_model_name }} selected</p>
<form action="." method="post" class="form form-horizontal">
{% csrf_token %}
{% if request.POST.return_url %}
@@ -27,7 +28,7 @@
</div>
{% endif %}
<div class="panel panel-default">
<div class="panel-heading"><strong>{{ component_name|title }} to Add</strong></div>
<div class="panel-heading"><strong>{{ model_name|title }} to Add</strong></div>
<div class="panel-body">
{% for field in form.visible_fields %}
{% render_field field %}

View File

@@ -78,17 +78,26 @@ class ChoiceField(Field):
return data
def to_internal_value(self, data):
# Provide an explicit error message if the request is trying to write a dict
if type(data) is dict:
raise ValidationError('Value must be passed directly (e.g. "foo": 123); do not use a dictionary.')
# Check for string representations of boolean/integer values
if hasattr(data, 'lower'):
# Hotwiring boolean values from string
if data.lower() == 'true':
return True
if data.lower() == 'false':
return False
# Check for string representation of an integer (e.g. "123")
try:
data = int(data)
except ValueError:
pass
data = True
elif data.lower() == 'false':
data = False
else:
try:
data = int(data)
except ValueError:
pass
if data not in self._choices:
raise ValidationError("{} is not a valid choice.".format(data))
return data

View File

@@ -1,11 +1,13 @@
import datetime
import json
import re
from django import template
from django.utils.safestring import mark_safe
from markdown import markdown
from utilities.forms import unpack_grouped_choices
from utilities.utils import foreground_color
register = template.Library()
@@ -152,6 +154,17 @@ def tzoffset(value):
return datetime.datetime.now(value).strftime('%z')
@register.filter()
def fgcolor(value):
"""
Return black (#000000) or white (#ffffff) given an arbitrary background color in RRGGBB format.
"""
value = value.lower().strip('#')
if not re.match('^[0-9a-f]{6}$', value):
return ''
return '#{}'.format(foreground_color(value))
#
# Tags
#

View File

@@ -55,8 +55,9 @@ class GetReturnURLMixin(object):
def get_return_url(self, request, obj=None):
# First, see if `return_url` was specified as a query parameter. Use it only if it's considered safe.
query_param = request.GET.get('return_url')
# First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's
# considered safe.
query_param = request.GET.get('return_url') or request.POST.get('return_url')
if query_param and is_safe_url(url=query_param, allowed_hosts=request.get_host()):
return query_param
@@ -789,9 +790,12 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
def post(self, request):
parent_model_name = self.parent_model._meta.verbose_name_plural
model_name = self.model._meta.verbose_name_plural
# Are we editing *all* objects in the queryset or just a selected subset?
if request.POST.get('_all') and self.filter is not None:
pk_list = [obj.pk for obj in self.filter(request.GET, self.model.objects.only('pk')).qs]
pk_list = [obj.pk for obj in self.filter(request.GET, self.parent_model.objects.only('pk')).qs]
else:
pk_list = [int(pk) for pk in request.POST.getlist('pk')]
@@ -829,9 +833,9 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
messages.success(request, "Added {} {} to {} {}.".format(
len(new_components),
self.model._meta.verbose_name_plural,
model_name,
len(form.cleaned_data['pk']),
self.parent_model._meta.verbose_name_plural
parent_model_name
))
return redirect(self.get_return_url(request))
@@ -840,7 +844,8 @@ class BulkComponentCreateView(GetReturnURLMixin, View):
return render(request, self.template_name, {
'form': form,
'component_name': self.model._meta.verbose_name_plural,
'parent_model_name': parent_model_name,
'model_name': model_name,
'table': table,
'return_url': self.get_return_url(request),
})

View File

@@ -369,5 +369,6 @@ class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentC
form = forms.VirtualMachineBulkAddInterfaceForm
model = Interface
model_form = forms.InterfaceForm
filter = filters.VirtualMachineFilter
table = tables.VirtualMachineTable
default_return_url = 'virtualization:virtualmachine_list'