Merge pull request #2778 from digitalocean/develop

Release v2.5.3
This commit is contained in:
Jeremy Stretch 2019-01-11 11:32:49 -05:00 committed by GitHub
commit e17d79e10f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 257 additions and 90 deletions

View File

@ -17,15 +17,20 @@ about: Report a reproducible bug in the current release of NetBox
--> -->
### Environment ### Environment
* Python version: <!-- Example: 3.5.4 --> * 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 Describe in detail the exact steps that someone else can take to reproduce
bug using the current stable release of NetBox (or the current beta release this bug using the current stable release of NetBox (or the current beta
where applicable). 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 ### Steps to Reproduce
1.
2.
3.
<!-- What did you expect to happen? --> <!-- What did you expect to happen? -->
### Expected Behavior ### Expected Behavior

View File

@ -1,3 +1,25 @@
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) v2.5.2 (2018-12-21)
## Enhancements ## Enhancements

View File

@ -35,13 +35,18 @@ from .exceptions import MissingFilterException
class DCIMFieldChoicesViewSet(FieldChoicesViewSet): class DCIMFieldChoicesViewSet(FieldChoicesViewSet):
fields = ( fields = (
(Cable, ['length_unit']), (Cable, ['length_unit', 'status', 'type']),
(Device, ['face', 'status']),
(ConsolePort, ['connection_status']), (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']), (InterfaceTemplate, ['form_factor']),
(PowerPort, ['connection_status']), (PowerPort, ['connection_status']),
(Rack, ['outer_unit', 'status', 'type', 'width']), (Rack, ['outer_unit', 'status', 'type', 'width']),
(RearPort, ['type']),
(RearPortTemplate, ['type']),
(Site, ['status']), (Site, ['status']),
) )

View File

@ -339,11 +339,14 @@ CABLE_TYPE_CAT5E = 1510
CABLE_TYPE_CAT6 = 1600 CABLE_TYPE_CAT6 = 1600
CABLE_TYPE_CAT6A = 1610 CABLE_TYPE_CAT6A = 1610
CABLE_TYPE_CAT7 = 1700 CABLE_TYPE_CAT7 = 1700
CABLE_TYPE_DAC_ACTIVE = 1800
CABLE_TYPE_DAC_PASSIVE = 1810
CABLE_TYPE_MMF_OM1 = 3010 CABLE_TYPE_MMF_OM1 = 3010
CABLE_TYPE_MMF_OM2 = 3020 CABLE_TYPE_MMF_OM2 = 3020
CABLE_TYPE_MMF_OM3 = 3030 CABLE_TYPE_MMF_OM3 = 3030
CABLE_TYPE_MMF_OM4 = 3040 CABLE_TYPE_MMF_OM4 = 3040
CABLE_TYPE_SMF = 3500 CABLE_TYPE_SMF = 3500
CABLE_TYPE_AOC = 3800
CABLE_TYPE_POWER = 5000 CABLE_TYPE_POWER = 5000
CABLE_TYPE_CHOICES = ( CABLE_TYPE_CHOICES = (
( (
@ -354,6 +357,8 @@ CABLE_TYPE_CHOICES = (
(CABLE_TYPE_CAT6, 'CAT6'), (CABLE_TYPE_CAT6, 'CAT6'),
(CABLE_TYPE_CAT6A, 'CAT6a'), (CABLE_TYPE_CAT6A, 'CAT6a'),
(CABLE_TYPE_CAT7, 'CAT7'), (CABLE_TYPE_CAT7, 'CAT7'),
(CABLE_TYPE_DAC_ACTIVE, 'Direct Attach Copper (Active)'),
(CABLE_TYPE_DAC_PASSIVE, 'Direct Attach Copper (Passive)'),
), ),
), ),
( (
@ -363,6 +368,7 @@ CABLE_TYPE_CHOICES = (
(CABLE_TYPE_MMF_OM3, 'Multimode Fiber (OM3)'), (CABLE_TYPE_MMF_OM3, 'Multimode Fiber (OM3)'),
(CABLE_TYPE_MMF_OM4, 'Multimode Fiber (OM4)'), (CABLE_TYPE_MMF_OM4, 'Multimode Fiber (OM4)'),
(CABLE_TYPE_SMF, 'Singlemode Fiber'), (CABLE_TYPE_SMF, 'Singlemode Fiber'),
(CABLE_TYPE_AOC, 'Active Optical Cabling (AOC)'),
), ),
), ),
(CABLE_TYPE_POWER, 'Power'), (CABLE_TYPE_POWER, 'Power'),

View File

@ -62,14 +62,14 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
choices=SITE_STATUS_CHOICES, choices=SITE_STATUS_CHOICES,
null_value=None null_value=None
) )
region_id = django_filters.ModelMultipleChoiceFilter( region_id = django_filters.NumberFilter(
queryset=Region.objects.all(), method='filter_region',
field_name='pk',
label='Region (ID)', label='Region (ID)',
) )
region = django_filters.ModelMultipleChoiceFilter( region = django_filters.CharFilter(
field_name='region__slug', method='filter_region',
queryset=Region.objects.all(), field_name='slug',
to_field_name='slug',
label='Region (slug)', label='Region (slug)',
) )
tenant_id = django_filters.ModelMultipleChoiceFilter( tenant_id = django_filters.ModelMultipleChoiceFilter(
@ -108,6 +108,16 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
pass pass
return queryset.filter(qs_filter) 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): class RackGroupFilter(django_filters.FilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(

View File

@ -58,6 +58,22 @@ class BulkRenameForm(forms.Form):
""" """
find = forms.CharField() find = forms.CharField()
replace = 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 required=False
) )
region = FilterTreeNodeMultipleChoiceField( region = FilterTreeNodeMultipleChoiceField(
queryset=Region.objects.annotate(filter_count=Count('sites')), queryset=Region.objects.all(),
to_field_name='slug', to_field_name='slug',
required=False, required=False,
count_attr='site_count'
) )
tenant = FilterChoiceField( tenant = FilterChoiceField(
queryset=Tenant.objects.annotate(filter_count=Count('sites')), queryset=Tenant.objects.annotate(filter_count=Count('sites')),
@ -1212,11 +1229,13 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm):
# Initialize helper selectors # Initialize helper selectors
instance = kwargs.get('instance') instance = kwargs.get('instance')
if 'initial' not in kwargs:
kwargs['initial'] = {}
# Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field # Using hasattr() instead of "is not None" to avoid RelatedObjectDoesNotExist on required field
if instance and hasattr(instance, 'device_type'): if instance and hasattr(instance, 'device_type'):
initial = kwargs.get('initial', {}).copy() kwargs['initial']['manufacturer'] = instance.device_type.manufacturer
initial['manufacturer'] = instance.device_type.manufacturer if instance and instance.cluster is not None:
kwargs['initial'] = initial kwargs['initial']['cluster_group'] = instance.cluster.group
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -201,6 +201,13 @@ class Region(MPTTModel, ChangeLoggedModel):
self.parent.name if self.parent else None, 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 # Sites
@ -2551,52 +2558,55 @@ class Cable(ChangeLoggedModel):
def clean(self): def clean(self):
# Check that termination types are compatible if self.termination_a and self.termination_b:
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
))
# A termination point cannot be connected to itself type_a = self.termination_a_type.model
if self.termination_a == self.termination_b: type_b = self.termination_b_type.model
raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
# A front port cannot be connected to its corresponding rear port # Check that termination types are compatible
if ( if type_b not in COMPATIBLE_TERMINATION_TYPES.get(type_a):
type_a in ['frontport', 'rearport'] and raise ValidationError("Incompatible termination types: {} and {}".format(
type_b in ['frontport', 'rearport'] and self.termination_a_type, self.termination_b_type
( ))
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 # A termination point cannot be connected to itself
if self.termination_a.cable not in (None, self): if self.termination_a == self.termination_b:
raise ValidationError("{} already has a cable attached (#{})".format( raise ValidationError("Cannot connect {} to itself".format(self.termination_a_type))
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 # A front port cannot be connected to its corresponding rear port
endpoint_a, endpoint_b, _ = self.get_path_endpoints() if (
if ( type_a in ['frontport', 'rearport'] and
( type_b in ['frontport', 'rearport'] and
isinstance(endpoint_a, Interface) and (
endpoint_a.form_factor == IFACE_FF_VIRTUAL getattr(self.termination_a, 'rear_port', None) == self.termination_b or
) or getattr(self.termination_b, 'rear_port', None) == self.termination_a
( )
isinstance(endpoint_b, Interface) and ):
endpoint_b.form_factor == IFACE_FF_VIRTUAL raise ValidationError("A front port cannot be connected to it corresponding rear port")
)
): # Check for an existing Cable connected to either termination object
raise ValidationError("Cannot connect to a virtual interface") 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 # Validate length and length_unit
if self.length is not None and self.length_unit is None: if self.length is not None and self.length_unit is None:

View File

@ -1,3 +1,5 @@
import re
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.core.paginator import EmptyPage, PageNotAnInteger from django.core.paginator import EmptyPage, PageNotAnInteger
@ -50,7 +52,16 @@ class BulkRenameView(GetReturnURLMixin, View):
if form.is_valid(): if form.is_valid():
for obj in selected_objects: 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: if '_apply' in request.POST:
for obj in selected_objects: for obj in selected_objects:
@ -124,7 +135,7 @@ class BulkDisconnectView(GetReturnURLMixin, View):
# #
class RegionListView(ObjectListView): class RegionListView(ObjectListView):
queryset = Region.objects.annotate(site_count=Count('sites')) queryset = Region.objects.all()
filter = filters.RegionFilter filter = filters.RegionFilter
filter_form = forms.RegionFilterForm filter_form = forms.RegionFilterForm
table = tables.RegionTable table = tables.RegionTable

View File

@ -422,6 +422,11 @@ class PrefixBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
required=False, required=False,
label='VRF' label='VRF'
) )
prefix_length = forms.IntegerField(
min_value=1,
max_value=127,
required=False
)
tenant = forms.ModelChoiceField( tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False required=False
@ -819,6 +824,11 @@ class IPAddressBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEd
required=False, required=False,
label='VRF' label='VRF'
) )
mask_length = forms.IntegerField(
min_value=1,
max_value=128,
required=False
)
tenant = forms.ModelChoiceField( tenant = forms.ModelChoiceField(
queryset=Tenant.objects.all(), queryset=Tenant.objects.all(),
required=False required=False

View File

@ -385,6 +385,15 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
self.description, 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): def get_status_class(self):
return STATUS_CHOICE_CLASSES[self.status] 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()]) child_ips = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()])
available_ips = prefix - child_ips available_ips = prefix - child_ips
# Remove unusable IPs from non-pool prefixes # All IP addresses within a pool are considered usable
if not self.is_pool: if self.is_pool:
available_ips -= netaddr.IPSet([ return available_ips
netaddr.IPAddress(self.prefix.first),
netaddr.IPAddress(self.prefix.last), # 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 return available_ips
@ -630,6 +650,15 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
self.description, 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 @property
def device(self): def device(self):
if self.interface: if self.interface:

View File

@ -15,6 +15,7 @@ OBJ_TYPE_CHOICES = (
('devicetype', 'Device types'), ('devicetype', 'Device types'),
('device', 'Devices'), ('device', 'Devices'),
('virtualchassis', 'Virtual Chassis'), ('virtualchassis', 'Virtual Chassis'),
('cable', 'Cables'),
)), )),
('IPAM', ( ('IPAM', (
('vrf', 'VRFs'), ('vrf', 'VRFs'),

View File

@ -22,7 +22,7 @@ except ImportError:
) )
VERSION = '2.5.2' VERSION = '2.5.3'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -246,6 +246,14 @@ LOGIN_URL = '/{}login/'.format(BASE_PATH)
# Secrets # Secrets
SECRETS_MIN_PUBKEY_SIZE = 2048 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 # Django filters
FILTERS_NULL_CHOICE_LABEL = 'None' FILTERS_NULL_CHOICE_LABEL = 'None'
FILTERS_NULL_CHOICE_VALUE = 'null' FILTERS_NULL_CHOICE_VALUE = 'null'

View File

@ -11,13 +11,13 @@ from circuits.filters import CircuitFilter, ProviderFilter
from circuits.models import Circuit, Provider from circuits.models import Circuit, Provider
from circuits.tables import CircuitTable, ProviderTable from circuits.tables import CircuitTable, ProviderTable
from dcim.filters import ( from dcim.filters import (
DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter CableFilter, DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
) )
from dcim.models import ( from dcim.models import (
Cable, ConsolePort, Device, DeviceType, Interface, PowerPort, Rack, RackGroup, Site, VirtualChassis Cable, ConsolePort, Device, DeviceType, Interface, PowerPort, Rack, RackGroup, Site, VirtualChassis
) )
from dcim.tables import ( 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 extras.models import ObjectChange, ReportResult, TopologyMap
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
@ -88,6 +88,12 @@ SEARCH_TYPES = OrderedDict((
'table': VirtualChassisTable, 'table': VirtualChassisTable,
'url': 'dcim:virtualchassis_list', 'url': 'dcim:virtualchassis_list',
}), }),
('cable', {
'queryset': Cable.objects.all(),
'filter': CableFilter,
'table': CableTable,
'url': 'dcim:cable_list',
}),
# IPAM # IPAM
('vrf', { ('vrf', {
'queryset': VRF.objects.select_related('tenant'), 'queryset': VRF.objects.select_related('tenant'),

View File

@ -140,6 +140,9 @@ table.attr-table td:nth-child(1) {
div.paginator { div.paginator {
margin-bottom: 20px; margin-bottom: 20px;
} }
div.paginator form {
margin-bottom: 6px;
}
nav ul.pagination { nav ul.pagination {
margin-top: 0; margin-top: 0;
margin-bottom: 8px !important; margin-bottom: 8px !important;

View File

@ -1,5 +1,10 @@
$(document).ready(function() { $(document).ready(function() {
// Pagination
$('select#per_page').change(function() {
this.form.submit();
});
// "Toggle" checkbox for object lists (PK column) // "Toggle" checkbox for object lists (PK column)
$('input:checkbox.toggle').click(function() { $('input:checkbox.toggle').click(function() {
$(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked')); $(this).closest('table').find('input:checkbox[name=pk]').prop('checked', $(this).prop('checked'));

View File

@ -38,7 +38,7 @@
<td colspan="3" class="method"> <td colspan="3" class="method">
{{ method }} {{ method }}
</td> </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-success">{{ stats.success }}</label>
<label class="label label-info">{{ stats.info }}</label> <label class="label label-info">{{ stats.info }}</label>
<label class="label label-warning">{{ stats.warning }}</label> <label class="label label-warning">{{ stats.warning }}</label>

View File

@ -1,6 +1,6 @@
{% load helpers %} {% load helpers %}
<div class="paginator pull-right"> <div class="paginator pull-right text-right">
{% if paginator.num_pages > 1 %} {% if paginator.num_pages > 1 %}
<nav> <nav>
<ul class="pagination pull-right"> <ul class="pagination pull-right">
@ -19,6 +19,18 @@
{% endif %} {% endif %}
</ul> </ul>
</nav> </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 %} {% endif %}
{% if page %} {% if page %}
<div class="text-right text-muted"> <div class="text-right text-muted">

View File

@ -3,6 +3,3 @@
<div class="table-responsive"> <div class="table-responsive">
{% render_table table 'inc/table.html' %} {% render_table table 'inc/table.html' %}
</div> </div>
{% with paginator=table.paginator page=table.page %}
{% include 'inc/paginator.html' %}
{% endwith %}

View File

@ -28,19 +28,22 @@
</div> </div>
{% endif %} {% endif %}
{% include table_template|default:'responsive_table.html' %} {% include table_template|default:'responsive_table.html' %}
{% block extra_actions %}{% endblock %} <div class="pull-left">
{% if bulk_edit_url and permissions.change %} {% block extra_actions %}{% endblock %}
<button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm"> {% if bulk_edit_url and permissions.change %}
<span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
</button> <span class="glyphicon glyphicon-pencil" aria-hidden="true"></span> Edit Selected
{% endif %} </button>
{% if bulk_delete_url and permissions.delete %} {% endif %}
<button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm"> {% if bulk_delete_url and permissions.delete %}
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
</button> <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete Selected
{% endif %} </button>
{% endif %}
</div>
</form> </form>
{% else %} {% else %}
{% include table_template|default:'responsive_table.html' %} {% include table_template|default:'responsive_table.html' %}
{% endif %} {% endif %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
<div class="clearfix"></div> <div class="clearfix"></div>

View File

@ -2,6 +2,7 @@ COLOR_CHOICES = (
('aa1409', 'Dark red'), ('aa1409', 'Dark red'),
('f44336', 'Red'), ('f44336', 'Red'),
('e91e63', 'Pink'), ('e91e63', 'Pink'),
('ffe4e1', 'Rose'),
('ff66ff', 'Fuschia'), ('ff66ff', 'Fuschia'),
('9c27b0', 'Purple'), ('9c27b0', 'Purple'),
('673ab7', 'Dark purple'), ('673ab7', 'Dark purple'),
@ -10,6 +11,7 @@ COLOR_CHOICES = (
('03a9f4', 'Light blue'), ('03a9f4', 'Light blue'),
('00bcd4', 'Cyan'), ('00bcd4', 'Cyan'),
('009688', 'Teal'), ('009688', 'Teal'),
('00ffff', 'Aqua'),
('2f6a31', 'Dark green'), ('2f6a31', 'Dark green'),
('4caf50', 'Green'), ('4caf50', 'Green'),
('8bc34a', 'Light green'), ('8bc34a', 'Light green'),
@ -23,4 +25,5 @@ COLOR_CHOICES = (
('9e9e9e', 'Grey'), ('9e9e9e', 'Grey'),
('607d8b', 'Dark grey'), ('607d8b', 'Dark grey'),
('111111', 'Black'), ('111111', 'Black'),
('ffffff', 'White'),
) )

View File

@ -505,8 +505,9 @@ class FilterChoiceIterator(forms.models.ModelChoiceIterator):
class FilterChoiceFieldMixin(object): class FilterChoiceFieldMixin(object):
iterator = FilterChoiceIterator 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.null_label = null_label
self.count_attr = count_attr
if 'required' not in kwargs: if 'required' not in kwargs:
kwargs['required'] = False kwargs['required'] = False
if 'widget' not in kwargs: if 'widget' not in kwargs:
@ -515,8 +516,9 @@ class FilterChoiceFieldMixin(object):
def label_from_instance(self, obj): def label_from_instance(self, obj):
label = super().label_from_instance(obj) label = super().label_from_instance(obj)
if hasattr(obj, 'filter_count'): obj_count = getattr(obj, self.count_attr, None)
return '{} ({})'.format(label, obj.filter_count) if obj_count is not None:
return '{} ({})'.format(label, obj_count)
return label return label