mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-25 03:08:15 +01:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e17d79e10f | ||
|
|
28a2981a4f | ||
|
|
d356e288a2 | ||
|
|
dd5f37391f | ||
|
|
c89735cd4e | ||
|
|
5676bd15dd | ||
|
|
0d415d94a5 | ||
|
|
73a1d6a7ba | ||
|
|
f7f6704fc1 | ||
|
|
170e01b549 | ||
|
|
99dc46a89e | ||
|
|
848aa0b098 | ||
|
|
0a820d9c98 | ||
|
|
209a9f0ffc | ||
|
|
3101a86381 | ||
|
|
27ca0d0930 | ||
|
|
6ca045e1a9 | ||
|
|
0c86693dc4 | ||
|
|
c6d9206dd1 | ||
|
|
d144d3a584 | ||
|
|
8cf8710130 | ||
|
|
3705e37678 | ||
|
|
ebe5193348 | ||
|
|
a3097d254e | ||
|
|
38276d9539 | ||
|
|
91a2168952 | ||
|
|
4a10b4ece0 | ||
|
|
853b1fad15 | ||
|
|
7acbeb55bc | ||
|
|
8498e0088b | ||
|
|
aae10f7d71 | ||
|
|
6b19a2b101 | ||
|
|
b44a76e6bd | ||
|
|
7f71fc1d42 | ||
|
|
ba9fe408bc | ||
|
|
40cb576e11 | ||
|
|
2f1db2fdf3 | ||
|
|
f4a22e5af3 | ||
|
|
aca57ec281 | ||
|
|
68cb8b6895 | ||
|
|
82e8c0152e | ||
|
|
f499f2dd66 | ||
|
|
d4a9318826 | ||
|
|
064dd9bef2 |
15
.github/ISSUE_TEMPLATE/bug_report.md
vendored
15
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -17,15 +17,20 @@ about: Report a reproducible bug in the current release of NetBox
|
||||
-->
|
||||
### Environment
|
||||
* Python version: <!-- Example: 3.5.4 -->
|
||||
* NetBox version: <!-- Example: 2.3.6 -->
|
||||
* NetBox version: <!-- Example: 2.5.2 -->
|
||||
|
||||
<!--
|
||||
Describe in detail the steps that someone else can take to reproduce this
|
||||
bug using the current stable release of NetBox (or the current beta release
|
||||
where applicable).
|
||||
Describe in detail the exact steps that someone else can take to reproduce
|
||||
this bug using the current stable release of NetBox (or the current beta
|
||||
release where applicable). Begin with the creation of any necessary
|
||||
database objects and call out every operation being performed explicitly.
|
||||
If reporting a bug in the REST API, be sure to reconstruct the raw HTTP
|
||||
request(s) being made: Don't rely on a wrapper like pynetbox.
|
||||
-->
|
||||
### Steps to Reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
<!-- What did you expect to happen? -->
|
||||
### Expected Behavior
|
||||
|
||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -1,3 +1,47 @@
|
||||
v2.5.3 (2019-01-11)
|
||||
|
||||
## Enhancements
|
||||
|
||||
* [#1630](https://github.com/digitalocean/netbox/issues/1630) - Enable bulk editing of prefix/IP mask length
|
||||
* [#1870](https://github.com/digitalocean/netbox/issues/1870) - Add per-page toggle to object lists
|
||||
* [#1871](https://github.com/digitalocean/netbox/issues/1871) - Enable filtering sites by parent region
|
||||
* [#1983](https://github.com/digitalocean/netbox/issues/1983) - Enable regular expressions when bulk renaming device components
|
||||
* [#2682](https://github.com/digitalocean/netbox/issues/2682) - Add DAC and AOC cable types
|
||||
* [#2693](https://github.com/digitalocean/netbox/issues/2693) - Additional cable colors
|
||||
* [#2726](https://github.com/digitalocean/netbox/issues/2726) - Include cables in global search
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
* [#2742](https://github.com/digitalocean/netbox/issues/2742) - Preserve cluster assignment when editing a device
|
||||
* [#2757](https://github.com/digitalocean/netbox/issues/2757) - Always treat first/last IPs within a /31 or /127 as usable
|
||||
* [#2762](https://github.com/digitalocean/netbox/issues/2762) - Add missing DCIM field values to API `_choices` endpoint
|
||||
* [#2777](https://github.com/digitalocean/netbox/issues/2777) - Fix cable validation to handle duplicate connections on import
|
||||
|
||||
|
||||
---
|
||||
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -35,13 +35,18 @@ from .exceptions import MissingFilterException
|
||||
|
||||
class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
|
||||
fields = (
|
||||
(Cable, ['length_unit']),
|
||||
(Device, ['face', 'status']),
|
||||
(Cable, ['length_unit', 'status', 'type']),
|
||||
(ConsolePort, ['connection_status']),
|
||||
(Interface, ['connection_status', 'form_factor', 'mode']),
|
||||
(Device, ['face', 'status']),
|
||||
(DeviceType, ['subdevice_role']),
|
||||
(FrontPort, ['type']),
|
||||
(FrontPortTemplate, ['type']),
|
||||
(Interface, ['form_factor', 'mode']),
|
||||
(InterfaceTemplate, ['form_factor']),
|
||||
(PowerPort, ['connection_status']),
|
||||
(Rack, ['outer_unit', 'status', 'type', 'width']),
|
||||
(RearPort, ['type']),
|
||||
(RearPortTemplate, ['type']),
|
||||
(Site, ['status']),
|
||||
)
|
||||
|
||||
@@ -60,7 +65,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')
|
||||
|
||||
@@ -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
|
||||
@@ -153,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)'],
|
||||
]
|
||||
],
|
||||
[
|
||||
@@ -333,11 +339,14 @@ CABLE_TYPE_CAT5E = 1510
|
||||
CABLE_TYPE_CAT6 = 1600
|
||||
CABLE_TYPE_CAT6A = 1610
|
||||
CABLE_TYPE_CAT7 = 1700
|
||||
CABLE_TYPE_DAC_ACTIVE = 1800
|
||||
CABLE_TYPE_DAC_PASSIVE = 1810
|
||||
CABLE_TYPE_MMF_OM1 = 3010
|
||||
CABLE_TYPE_MMF_OM2 = 3020
|
||||
CABLE_TYPE_MMF_OM3 = 3030
|
||||
CABLE_TYPE_MMF_OM4 = 3040
|
||||
CABLE_TYPE_SMF = 3500
|
||||
CABLE_TYPE_AOC = 3800
|
||||
CABLE_TYPE_POWER = 5000
|
||||
CABLE_TYPE_CHOICES = (
|
||||
(
|
||||
@@ -348,6 +357,8 @@ CABLE_TYPE_CHOICES = (
|
||||
(CABLE_TYPE_CAT6, 'CAT6'),
|
||||
(CABLE_TYPE_CAT6A, 'CAT6a'),
|
||||
(CABLE_TYPE_CAT7, 'CAT7'),
|
||||
(CABLE_TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'),
|
||||
(CABLE_TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'),
|
||||
),
|
||||
),
|
||||
(
|
||||
@@ -357,6 +368,7 @@ CABLE_TYPE_CHOICES = (
|
||||
(CABLE_TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
|
||||
(CABLE_TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
|
||||
(CABLE_TYPE_SMF, 'Singlemode Fiber'),
|
||||
(CABLE_TYPE_AOC, 'Active Optical Cabling (AOC)'),
|
||||
),
|
||||
),
|
||||
(CABLE_TYPE_POWER, 'Power'),
|
||||
|
||||
5
netbox/dcim/exceptions.py
Normal file
5
netbox/dcim/exceptions.py
Normal file
@@ -0,0 +1,5 @@
|
||||
class LoopDetected(Exception):
|
||||
"""
|
||||
A loop has been detected while tracing a cable path.
|
||||
"""
|
||||
pass
|
||||
@@ -62,14 +62,14 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
choices=SITE_STATUS_CHOICES,
|
||||
null_value=None
|
||||
)
|
||||
region_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=Region.objects.all(),
|
||||
region_id = django_filters.NumberFilter(
|
||||
method='filter_region',
|
||||
field_name='pk',
|
||||
label='Region (ID)',
|
||||
)
|
||||
region = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='region__slug',
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
region = django_filters.CharFilter(
|
||||
method='filter_region',
|
||||
field_name='slug',
|
||||
label='Region (slug)',
|
||||
)
|
||||
tenant_id = django_filters.ModelMultipleChoiceFilter(
|
||||
@@ -108,6 +108,16 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
|
||||
pass
|
||||
return queryset.filter(qs_filter)
|
||||
|
||||
def filter_region(self, queryset, name, value):
|
||||
try:
|
||||
region = Region.objects.get(**{name: value})
|
||||
except ObjectDoesNotExist:
|
||||
return queryset.none()
|
||||
return queryset.filter(
|
||||
Q(region=region) |
|
||||
Q(region__in=region.get_descendants())
|
||||
)
|
||||
|
||||
|
||||
class RackGroupFilter(django_filters.FilterSet):
|
||||
q = django_filters.CharFilter(
|
||||
|
||||
@@ -58,6 +58,22 @@ class BulkRenameForm(forms.Form):
|
||||
"""
|
||||
find = forms.CharField()
|
||||
replace = forms.CharField()
|
||||
use_regex = forms.BooleanField(
|
||||
required=False,
|
||||
initial=True,
|
||||
label='Use regular expressions'
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Validate regular expression in "find" field
|
||||
if self.cleaned_data['use_regex']:
|
||||
try:
|
||||
re.compile(self.cleaned_data['find'])
|
||||
except re.error:
|
||||
raise forms.ValidationError({
|
||||
'find': "Invalid regular expression"
|
||||
})
|
||||
|
||||
|
||||
#
|
||||
@@ -236,9 +252,10 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm):
|
||||
required=False
|
||||
)
|
||||
region = FilterTreeNodeMultipleChoiceField(
|
||||
queryset=Region.objects.annotate(filter_count=Count('sites')),
|
||||
queryset=Region.objects.all(),
|
||||
to_field_name='slug',
|
||||
required=False,
|
||||
count_attr='site_count'
|
||||
)
|
||||
tenant = FilterChoiceField(
|
||||
queryset=Tenant.objects.annotate(filter_count=Count('sites')),
|
||||
@@ -1212,11 +1229,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
|
||||
|
||||
# Initialize helper selectors
|
||||
instance = kwargs.get('instance')
|
||||
if 'initial' not in kwargs:
|
||||
kwargs['initial'] = {}
|
||||
# Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
|
||||
if instance and hasattr(instance, 'device_type'):
|
||||
initial = kwargs.get('initial', {}).copy()
|
||||
initial['manufacturer'] = instance.device_type.manufacturer
|
||||
kwargs['initial'] = initial
|
||||
kwargs['initial']['manufacturer'] = instance.device_type.manufacturer
|
||||
if instance and instance.cluster is not None:
|
||||
kwargs['initial']['cluster_group'] = instance.cluster.group
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@@ -2098,6 +2117,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 +2731,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
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -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:
|
||||
[
|
||||
@@ -133,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)]
|
||||
|
||||
@@ -140,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)]
|
||||
|
||||
@@ -189,6 +201,13 @@ class Region(MPTTModel, ChangeLoggedModel):
|
||||
self.parent.name if self.parent else None,
|
||||
)
|
||||
|
||||
@property
|
||||
def site_count(self):
|
||||
return Site.objects.filter(
|
||||
Q(region=self) |
|
||||
Q(region__in=self.get_descendants())
|
||||
).count()
|
||||
|
||||
|
||||
#
|
||||
# Sites
|
||||
@@ -2539,52 +2558,55 @@ class Cable(ChangeLoggedModel):
|
||||
|
||||
def clean(self):
|
||||
|
||||
# Check that termination types are compatible
|
||||
type_a = self.termination_a_type.model
|
||||
type_b = self.termination_b_type.model
|
||||
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
|
||||
raise ValidationError("Incompatible termination types: {} and {}".format(
|
||||
self.termination_a_type, self.termination_b_type
|
||||
))
|
||||
if self.termination_a and self.termination_b:
|
||||
|
||||
# A termination point cannot be connected to itself
|
||||
if self.termination_a == self.termination_b:
|
||||
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
|
||||
type_a = self.termination_a_type.model
|
||||
type_b = self.termination_b_type.model
|
||||
|
||||
# A front port cannot be connected to its corresponding rear port
|
||||
if (
|
||||
type_a in ['frontport', 'rearport'] and
|
||||
type_b in ['frontport', 'rearport'] and
|
||||
(
|
||||
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
|
||||
getattr(self.termination_b, 'rear_port', None) == self.termination_a
|
||||
)
|
||||
):
|
||||
raise ValidationError("A front port cannot be connected to it corresponding rear port")
|
||||
# Check that termination types are compatible
|
||||
if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
|
||||
raise ValidationError("Incompatible termination types: {} and {}".format(
|
||||
self.termination_a_type, self.termination_b_type
|
||||
))
|
||||
|
||||
# Check for an existing Cable connected to either termination object
|
||||
if self.termination_a.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_a, self.termination_a.cable_id
|
||||
))
|
||||
if self.termination_b.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_b, self.termination_b.cable_id
|
||||
))
|
||||
# A termination point cannot be connected to itself
|
||||
if self.termination_a == self.termination_b:
|
||||
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
|
||||
|
||||
# Virtual interfaces cannot be connected
|
||||
endpoint_a, endpoint_b, _ = self.get_path_endpoints()
|
||||
if (
|
||||
(
|
||||
isinstance(endpoint_a, Interface) and
|
||||
endpoint_a.form_factor == IFACE_FF_VIRTUAL
|
||||
) or
|
||||
(
|
||||
isinstance(endpoint_b, Interface) and
|
||||
endpoint_b.form_factor == IFACE_FF_VIRTUAL
|
||||
)
|
||||
):
|
||||
raise ValidationError("Cannot connect to a virtual interface")
|
||||
# A front port cannot be connected to its corresponding rear port
|
||||
if (
|
||||
type_a in ['frontport', 'rearport'] and
|
||||
type_b in ['frontport', 'rearport'] and
|
||||
(
|
||||
getattr(self.termination_a, 'rear_port', None) == self.termination_b or
|
||||
getattr(self.termination_b, 'rear_port', None) == self.termination_a
|
||||
)
|
||||
):
|
||||
raise ValidationError("A front port cannot be connected to it corresponding rear port")
|
||||
|
||||
# Check for an existing Cable connected to either termination object
|
||||
if self.termination_a.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_a, self.termination_a.cable_id
|
||||
))
|
||||
if self.termination_b.cable not in (None, self):
|
||||
raise ValidationError("{} already has a cable attached (#{})".format(
|
||||
self.termination_b, self.termination_b.cable_id
|
||||
))
|
||||
|
||||
# Virtual interfaces cannot be connected
|
||||
endpoint_a, endpoint_b, _ = self.get_path_endpoints()
|
||||
if (
|
||||
(
|
||||
isinstance(endpoint_a, Interface) and
|
||||
endpoint_a.form_factor == IFACE_FF_VIRTUAL
|
||||
) or
|
||||
(
|
||||
isinstance(endpoint_b, Interface) and
|
||||
endpoint_b.form_factor == IFACE_FF_VIRTUAL
|
||||
)
|
||||
):
|
||||
raise ValidationError("Cannot connect to a virtual interface")
|
||||
|
||||
# Validate length and length_unit
|
||||
if self.length is not None and self.length_unit is None:
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import re
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.mixins import PermissionRequiredMixin
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger
|
||||
@@ -50,7 +52,16 @@ class BulkRenameView(GetReturnURLMixin, View):
|
||||
|
||||
if form.is_valid():
|
||||
for obj in selected_objects:
|
||||
obj.new_name = obj.name.replace(form.cleaned_data['find'], form.cleaned_data['replace'])
|
||||
find = form.cleaned_data['find']
|
||||
replace = form.cleaned_data['replace']
|
||||
if form.cleaned_data['use_regex']:
|
||||
try:
|
||||
obj.new_name = re.sub(find, replace, obj.name)
|
||||
# Catch regex group reference errors
|
||||
except re.error:
|
||||
obj.new_name = obj.name
|
||||
else:
|
||||
obj.new_name = obj.name.replace(find, replace)
|
||||
|
||||
if '_apply' in request.POST:
|
||||
for obj in selected_objects:
|
||||
@@ -124,7 +135,7 @@ class BulkDisconnectView(GetReturnURLMixin, View):
|
||||
#
|
||||
|
||||
class RegionListView(ObjectListView):
|
||||
queryset = Region.objects.annotate(site_count=Count('sites'))
|
||||
queryset = Region.objects.all()
|
||||
filter = filters.RegionFilter
|
||||
filter_form = forms.RegionFilterForm
|
||||
table = tables.RegionTable
|
||||
@@ -1530,6 +1541,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 +1553,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 +1565,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 +1577,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 +1589,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 +1601,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'
|
||||
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -422,6 +422,11 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
|
||||
required=False,
|
||||
label='VRF'
|
||||
)
|
||||
prefix_length = forms.IntegerField(
|
||||
min_value=1,
|
||||
max_value=127,
|
||||
required=False
|
||||
)
|
||||
tenant = forms.ModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
@@ -819,6 +824,11 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
|
||||
required=False,
|
||||
label='VRF'
|
||||
)
|
||||
mask_length = forms.IntegerField(
|
||||
min_value=1,
|
||||
max_value=128,
|
||||
required=False
|
||||
)
|
||||
tenant = forms.ModelChoiceField(
|
||||
queryset=Tenant.objects.all(),
|
||||
required=False
|
||||
|
||||
@@ -385,6 +385,15 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
|
||||
self.description,
|
||||
)
|
||||
|
||||
def _set_prefix_length(self, value):
|
||||
"""
|
||||
Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly,
|
||||
e.g. for bulk editing.
|
||||
"""
|
||||
if self.prefix is not None:
|
||||
self.prefix.prefixlen = value
|
||||
prefix_length = property(fset=_set_prefix_length)
|
||||
|
||||
def get_status_class(self):
|
||||
return STATUS_CHOICE_CLASSES[self.status]
|
||||
|
||||
@@ -429,12 +438,23 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
|
||||
child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
|
||||
available_ips = prefix - child_ips
|
||||
|
||||
# Remove unusable IPs from non-pool prefixes
|
||||
if not self.is_pool:
|
||||
available_ips -= netaddr.IPSet([
|
||||
netaddr.IPAddress(self.prefix.first),
|
||||
netaddr.IPAddress(self.prefix.last),
|
||||
])
|
||||
# All IP addresses within a pool are considered usable
|
||||
if self.is_pool:
|
||||
return available_ips
|
||||
|
||||
# All IP addresses within a point-to-point prefix (IPv4 /31 or IPv6 /127) are considered usable
|
||||
if (
|
||||
self.family == 4 and self.prefix.prefixlen == 31 # RFC 3021
|
||||
) or (
|
||||
self.family == 6 and self.prefix.prefixlen == 127 # RFC 6164
|
||||
):
|
||||
return available_ips
|
||||
|
||||
# Omit first and last IP address from the available set
|
||||
available_ips -= netaddr.IPSet([
|
||||
netaddr.IPAddress(self.prefix.first),
|
||||
netaddr.IPAddress(self.prefix.last),
|
||||
])
|
||||
|
||||
return available_ips
|
||||
|
||||
@@ -630,6 +650,15 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
self.description,
|
||||
)
|
||||
|
||||
def _set_mask_length(self, value):
|
||||
"""
|
||||
Expose the IPNetwork object's prefixlen attribute on the parent model so that it can be manipulated directly,
|
||||
e.g. for bulk editing.
|
||||
"""
|
||||
if self.address is not None:
|
||||
self.address.prefixlen = value
|
||||
mask_length = property(fset=_set_mask_length)
|
||||
|
||||
@property
|
||||
def device(self):
|
||||
if self.interface:
|
||||
|
||||
@@ -15,6 +15,7 @@ OBJ_TYPE_CHOICES = (
|
||||
('devicetype', 'Device types'),
|
||||
('device', 'Devices'),
|
||||
('virtualchassis', 'Virtual Chassis'),
|
||||
('cable', 'Cables'),
|
||||
)),
|
||||
('IPAM', (
|
||||
('vrf', 'VRFs'),
|
||||
|
||||
@@ -22,7 +22,7 @@ except ImportError:
|
||||
)
|
||||
|
||||
|
||||
VERSION = '2.5.1'
|
||||
VERSION = '2.5.3'
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
@@ -246,6 +246,14 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH)
|
||||
# Secrets
|
||||
SECRETS_MIN_PUBKEY_SIZE = 2048
|
||||
|
||||
# Pagination
|
||||
PER_PAGE_DEFAULTS = [
|
||||
25, 50, 100, 250, 500, 1000
|
||||
]
|
||||
if PAGINATE_COUNT not in PER_PAGE_DEFAULTS:
|
||||
PER_PAGE_DEFAULTS.append(PAGINATE_COUNT)
|
||||
PER_PAGE_DEFAULTS = sorted(PER_PAGE_DEFAULTS)
|
||||
|
||||
# Django filters
|
||||
FILTERS_NULL_CHOICE_LABEL = 'None'
|
||||
FILTERS_NULL_CHOICE_VALUE = 'null'
|
||||
|
||||
@@ -11,13 +11,13 @@ from circuits.filters import CircuitFilter, ProviderFilter
|
||||
from circuits.models import Circuit, Provider
|
||||
from circuits.tables import CircuitTable, ProviderTable
|
||||
from dcim.filters import (
|
||||
DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
|
||||
CableFilter, DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
|
||||
)
|
||||
from dcim.models import (
|
||||
Cable, ConsolePort, Device, DeviceType, Interface, PowerPort, Rack, RackGroup, Site, VirtualChassis
|
||||
)
|
||||
from dcim.tables import (
|
||||
DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable
|
||||
CableTable, DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable
|
||||
)
|
||||
from extras.models import ObjectChange, ReportResult, TopologyMap
|
||||
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
|
||||
@@ -88,6 +88,12 @@ SEARCH_TYPES = OrderedDict((
|
||||
'table': VirtualChassisTable,
|
||||
'url': 'dcim:virtualchassis_list',
|
||||
}),
|
||||
('cable', {
|
||||
'queryset': Cable.objects.all(),
|
||||
'filter': CableFilter,
|
||||
'table': CableTable,
|
||||
'url': 'dcim:cable_list',
|
||||
}),
|
||||
# IPAM
|
||||
('vrf', {
|
||||
'queryset': VRF.objects.select_related('tenant'),
|
||||
|
||||
@@ -140,6 +140,9 @@ table.attr-table td:nth-child(1) {
|
||||
div.paginator {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
div.paginator form {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
nav ul.pagination {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px !important;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
$(document).ready(function() {
|
||||
|
||||
// Pagination
|
||||
$('select#per_page').change(function() {
|
||||
this.form.submit();
|
||||
});
|
||||
|
||||
// "Toggle" checkbox for object lists (PK column)
|
||||
$('input:checkbox.toggle').click(function() {
|
||||
$(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));
|
||||
@@ -100,7 +105,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');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<td colspan="3" class="method">
|
||||
{{ method }}
|
||||
</td>
|
||||
<td class="text-right report-stats">
|
||||
<td class="text-right text-nowrap report-stats">
|
||||
<label class="label label-success">{{ stats.success }}</label>
|
||||
<label class="label label-info">{{ stats.info }}</label>
|
||||
<label class="label label-warning">{{ stats.warning }}</label>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{% load helpers %}
|
||||
|
||||
<div class="paginator pull-right">
|
||||
<div class="paginator pull-right text-right">
|
||||
{% if paginator.num_pages > 1 %}
|
||||
<nav>
|
||||
<ul class="pagination pull-right">
|
||||
@@ -19,6 +19,18 @@
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
<form method="get">
|
||||
{% for k, v in request.GET.items %}
|
||||
{% if k != 'per_page' %}
|
||||
<input type="hidden" name="{{ k }}" value="{{ v }}" />
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<select name="per_page" id="per_page">
|
||||
{% for n in settings.PER_PAGE_DEFAULTS %}
|
||||
<option value="{{ n }}"{% if page.paginator.per_page == n %} selected="selected"{% endif %}>{{ n }}</option>
|
||||
{% endfor %}
|
||||
</select> per page
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if page %}
|
||||
<div class="text-right text-muted">
|
||||
|
||||
@@ -3,6 +3,3 @@
|
||||
<div class="table-responsive">
|
||||
{% render_table table 'inc/table.html' %}
|
||||
</div>
|
||||
{% with paginator=table.paginator page=table.page %}
|
||||
{% include 'inc/paginator.html' %}
|
||||
{% endwith %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -28,19 +28,22 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
{% include table_template|default:'responsive_table.html' %}
|
||||
{% block extra_actions %}{% endblock %}
|
||||
{% if bulk_edit_url and permissions.change %}
|
||||
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bulk_delete_url and permissions.delete %}
|
||||
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
<div class="pull-left">
|
||||
{% block extra_actions %}{% endblock %}
|
||||
{% if bulk_edit_url and permissions.change %}
|
||||
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
|
||||
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if bulk_delete_url and permissions.delete %}
|
||||
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
|
||||
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
{% include table_template|default:'responsive_table.html' %}
|
||||
{% endif %}
|
||||
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
|
||||
<div class="clearfix"></div>
|
||||
|
||||
@@ -2,6 +2,7 @@ COLOR_CHOICES = (
|
||||
('aa1409', 'Dark red'),
|
||||
('f44336', 'Red'),
|
||||
('e91e63', 'Pink'),
|
||||
('ffe4e1', 'Rose'),
|
||||
('ff66ff', 'Fuschia'),
|
||||
('9c27b0', 'Purple'),
|
||||
('673ab7', 'Dark purple'),
|
||||
@@ -10,6 +11,7 @@ COLOR_CHOICES = (
|
||||
('03a9f4', 'Light blue'),
|
||||
('00bcd4', 'Cyan'),
|
||||
('009688', 'Teal'),
|
||||
('00ffff', 'Aqua'),
|
||||
('2f6a31', 'Dark green'),
|
||||
('4caf50', 'Green'),
|
||||
('8bc34a', 'Light green'),
|
||||
@@ -23,4 +25,5 @@ COLOR_CHOICES = (
|
||||
('9e9e9e', 'Grey'),
|
||||
('607d8b', 'Dark grey'),
|
||||
('111111', 'Black'),
|
||||
('ffffff', 'White'),
|
||||
)
|
||||
|
||||
@@ -505,8 +505,9 @@ class FilterChoiceIterator(forms.models.ModelChoiceIterator):
|
||||
class FilterChoiceFieldMixin(object):
|
||||
iterator = FilterChoiceIterator
|
||||
|
||||
def __init__(self, null_label=None, *args, **kwargs):
|
||||
def __init__(self, null_label=None, count_attr='filter_count', *args, **kwargs):
|
||||
self.null_label = null_label
|
||||
self.count_attr = count_attr
|
||||
if 'required' not in kwargs:
|
||||
kwargs['required'] = False
|
||||
if 'widget' not in kwargs:
|
||||
@@ -515,8 +516,9 @@ class FilterChoiceFieldMixin(object):
|
||||
|
||||
def label_from_instance(self, obj):
|
||||
label = super().label_from_instance(obj)
|
||||
if hasattr(obj, 'filter_count'):
|
||||
return '{} ({})'.format(label, obj.filter_count)
|
||||
obj_count = getattr(obj, self.count_attr, None)
|
||||
if obj_count is not None:
|
||||
return '{} ({})'.format(label, obj_count)
|
||||
return label
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
#
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user