diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 93b3e4af75..61d93f286d 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -17,7 +17,7 @@ body: What version of NetBox are you currently running? (If you don't have access to the most recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/) before opening a bug report to see if your issue has already been addressed.) - placeholder: v3.0.0 + placeholder: v3.0.1 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 7a7bcc1065..65f452f0b9 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.0.0 + placeholder: v3.0.1 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 93b8ae5655..1587d4b435 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -1,5 +1,36 @@ # NetBox v3.0 +## v3.0.1 (2021-09-01) + +### Bug Fixes + +* [#7041](https://github.com/netbox-community/netbox/issues/7041) - Properly format JSON config object returned from a NAPALM device +* [#7070](https://github.com/netbox-community/netbox/issues/7070) - Fix exception when filtering by prefix max length in UI +* [#7071](https://github.com/netbox-community/netbox/issues/7071) - Fix exception when removing a primary IP from a device/VM +* [#7072](https://github.com/netbox-community/netbox/issues/7072) - Fix table configuration under prefix child object views +* [#7075](https://github.com/netbox-community/netbox/issues/7075) - Fix UI bug when a custom field has a space in the name +* [#7080](https://github.com/netbox-community/netbox/issues/7080) - Fix missing image previews +* [#7081](https://github.com/netbox-community/netbox/issues/7081) - Fix UI bug that did not properly request and handle paginated data +* [#7082](https://github.com/netbox-community/netbox/issues/7082) - Avoid exception when referencing invalid content type in table +* [#7083](https://github.com/netbox-community/netbox/issues/7083) - Correct labeling for VM memory attribute +* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix KeyError exception when editing access VLAN on an interface +* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix issue where hidden VLAN form fields were incorrectly included in the form submission +* [#7089](https://github.com/netbox-community/netbox/issues/7089) - Fix filtering of change log by content type +* [#7090](https://github.com/netbox-community/netbox/issues/7090) - Allow decimal input on length field when bulk editing cables +* [#7091](https://github.com/netbox-community/netbox/issues/7091) - Ensure API requests from the UI are aware of `BASE_PATH` +* [#7092](https://github.com/netbox-community/netbox/issues/7092) - Fix missing bulk edit buttons on Prefix IP Addresses table +* [#7093](https://github.com/netbox-community/netbox/issues/7093) - Multi-select custom field filters should employ exact match +* [#7096](https://github.com/netbox-community/netbox/issues/7096) - Home links should honor `BASE_PATH` configuration +* [#7101](https://github.com/netbox-community/netbox/issues/7101) - Enforce `MAX_PAGE_SIZE` for table and REST API pagination +* [#7106](https://github.com/netbox-community/netbox/issues/7106) - Fix incorrect "Map It" button URL on a site's physical address field +* [#7107](https://github.com/netbox-community/netbox/issues/7107) - Fix missing search button and search results in IP address assignment "Assign IP" tab +* [#7109](https://github.com/netbox-community/netbox/issues/7109) - Ensure human readability of exceptions raised during REST API requests +* [#7113](https://github.com/netbox-community/netbox/issues/7113) - Show bulk edit/delete actions for prefix child objects +* [#7123](https://github.com/netbox-community/netbox/issues/7123) - Remove "Global" placeholder for null VRF field +* [#7124](https://github.com/netbox-community/netbox/issues/7124) - Fix duplicate static query param values in API Select + +--- + ## v3.0.0 (2021-08-30) !!! warning "Existing Deployments Must Upgrade from v2.11" diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 3ee225335a..3d23cde5c4 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -22,7 +22,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata from utilities.api import get_serializer_for_model -from utilities.utils import count_related +from utilities.utils import count_related, decode_dict from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -498,7 +498,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): response[method] = {'error': 'Only get_* NAPALM methods are supported'} continue try: - response[method] = getattr(d, method)() + response[method] = decode_dict(getattr(d, method)()) except NotImplementedError: response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)} except Exception as e: diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index b69944cf63..367980ac41 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -129,7 +129,7 @@ class InterfaceCommonForm(forms.Form): super().clean() parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine' - tagged_vlans = self.cleaned_data['tagged_vlans'] + tagged_vlans = self.cleaned_data.get('tagged_vlans') # Untagged interfaces cannot be assigned tagged VLANs if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans: @@ -142,7 +142,7 @@ class InterfaceCommonForm(forms.Form): self.cleaned_data['tagged_vlans'] = [] # Validate tagged VLANs; must be a global VLAN or in the same site - elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED: + elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans: valid_sites = [None, self.cleaned_data[parent_field].site] invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites] @@ -4586,8 +4586,8 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE color = ColorField( required=False ) - length = forms.IntegerField( - min_value=1, + length = forms.DecimalField( + min_value=0, required=False ) length_unit = forms.ChoiceField( diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index aef2046fda..b37aaf40e8 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -14,6 +14,7 @@ EXACT_FILTER_TYPES = ( CustomFieldTypeChoices.TYPE_DATE, CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_SELECT, + CustomFieldTypeChoices.TYPE_MULTISELECT, ) @@ -35,7 +36,9 @@ class CustomFieldFilter(django_filters.Filter): self.field_name = f'custom_field_data__{self.field_name}' - if custom_field.type not in EXACT_FILTER_TYPES: + if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT: + self.lookup_expr = 'has_key' + elif custom_field.type not in EXACT_FILTER_TYPES: if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE: self.lookup_expr = 'icontains' diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 2a6ae088c0..25fd32f0de 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -367,7 +367,19 @@ class JobResultFilterSet(BaseFilterSet): # class ContentTypeFilterSet(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) class Meta: model = ContentType fields = ['id', 'app_label', 'model'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(app_label__icontains=value) | + Q(model__icontains=value) + ) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index b1bf10be68..c2a2da3dc1 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -681,7 +681,12 @@ class CustomFieldFilterTest(TestCase): cf.content_types.set([obj_type]) # Selection filtering - cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz']) + cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Foo', 'Bar', 'Baz']) + cf.save() + cf.content_types.set([obj_type]) + + # Multiselect filtering + cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'AA', 'B', 'C']) cf.save() cf.content_types.set([obj_type]) @@ -695,6 +700,7 @@ class CustomFieldFilterTest(TestCase): 'cf6': 'http://foo.example.com/', 'cf7': 'http://foo.example.com/', 'cf8': 'Foo', + 'cf9': ['A', 'B'], }), Site(name='Site 2', slug='site-2', custom_field_data={ 'cf1': 200, @@ -705,9 +711,9 @@ class CustomFieldFilterTest(TestCase): 'cf6': 'http://bar.example.com/', 'cf7': 'http://bar.example.com/', 'cf8': 'Bar', + 'cf9': ['AA', 'B'], }), - Site(name='Site 3', slug='site-3', custom_field_data={ - }), + Site(name='Site 3', slug='site-3'), ]) def test_filter_integer(self): @@ -730,3 +736,10 @@ class CustomFieldFilterTest(TestCase): def test_filter_select(self): self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf8': 'Bar'}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf8': 'Baz'}, self.queryset).qs.count(), 0) + + def test_filter_multiselect(self): + self.assertEqual(self.filterset({'cf_cf9': 'A'}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf9': 'B'}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf9': 'C'}, self.queryset).qs.count(), 0) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 9525d9bb36..37a9299dcf 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -216,7 +216,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet): children = MultiValueNumberFilter( field_name='_children' ) - mask_length = django_filters.NumberFilter( + mask_length = MultiValueNumberFilter( field_name='prefix', lookup_expr='net_mask_length' ) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index f4977c9c30..4d5b3ad738 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -491,11 +491,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'status': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields['vrf'].empty_label = 'Global' - class PrefixCSVForm(CustomFieldModelCSVForm): vrf = CSVModelChoiceField( @@ -658,11 +653,11 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter label=_('Address family'), widget=StaticSelect() ) - mask_length = forms.ChoiceField( + mask_length = forms.MultipleChoiceField( required=False, choices=PREFIX_MASK_LENGTH_CHOICES, label=_('Mask length'), - widget=StaticSelect() + widget=StaticSelectMultiple() ) vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), @@ -760,11 +755,6 @@ class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'status': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields['vrf'].empty_label = 'Global' - class IPRangeCSVForm(CustomFieldModelCSVForm): vrf = CSVModelChoiceField( @@ -1026,8 +1016,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): super().__init__(*args, **kwargs) - self.fields['vrf'].empty_label = 'Global' - # Initialize primary_for_parent if IP address is already assigned if self.instance.pk and self.instance.assigned_object: parent = self.instance.assigned_object.parent_object @@ -1102,10 +1090,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'role': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['vrf'].empty_label = 'Global' - class IPAddressCSVForm(CustomFieldModelCSVForm): vrf = CSVModelChoiceField( @@ -1256,8 +1240,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): vrf_id = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF', - empty_label='Global' + label='VRF' ) q = forms.CharField( required=False, diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 6c1b2d4395..3e2e671caa 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -825,9 +825,9 @@ class IPAddress(PrimaryModel): if self.pk: for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')): parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() - if parent and getattr(self.assigned_object, attr) != parent: + if parent and getattr(self.assigned_object, attr, None) != parent: # Check for a NAT relationship - if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr) != parent: + if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr, None) != parent: raise ValidationError({ 'interface': f"IP address is primary for {cls._meta.model_name} {parent} but " f"not assigned to it!" diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index c82848aef9..ff9dbfece5 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -451,7 +451,7 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_mask_length(self): - params = {'mask_length': '24'} + params = {'mask_length': ['24']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_vrf(self): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index eed03885ec..f84760418a 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -403,13 +403,19 @@ class PrefixPrefixesView(generic.ObjectView): bulk_querystring = 'vrf_id={}&within={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix) + # Compile permissions list for rendering the object table + permissions = { + 'change': request.user.has_perm('ipam.change_prefix'), + 'delete': request.user.has_perm('ipam.delete_prefix'), + } + return { - 'first_available_prefix': instance.get_first_available_prefix(), 'table': table, + 'permissions': permissions, 'bulk_querystring': bulk_querystring, 'active_tab': 'prefixes', + 'first_available_prefix': instance.get_first_available_prefix(), 'show_available': request.GET.get('show_available', 'true') == 'true', - 'table_config_form': TableConfigForm(table=table), } @@ -421,15 +427,22 @@ class PrefixIPRangesView(generic.ObjectView): # Find all IPRanges belonging to this Prefix ip_ranges = instance.get_child_ranges().restrict(request.user, 'view').prefetch_related('vrf') - table = tables.IPRangeTable(ip_ranges) + table = tables.IPRangeTable(ip_ranges, user=request.user) if request.user.has_perm('ipam.change_iprange') or request.user.has_perm('ipam.delete_iprange'): table.columns.show('pk') paginate_table(table, request) bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix) + # Compile permissions list for rendering the object table + permissions = { + 'change': request.user.has_perm('ipam.change_iprange'), + 'delete': request.user.has_perm('ipam.delete_iprange'), + } + return { 'table': table, + 'permissions': permissions, 'bulk_querystring': bulk_querystring, 'active_tab': 'ip-ranges', } @@ -449,18 +462,25 @@ class PrefixIPAddressesView(generic.ObjectView): if request.GET.get('show_available', 'true') == 'true': ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool) - table = tables.IPAddressTable(ipaddresses) + table = tables.IPAddressTable(ipaddresses, user=request.user) if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'): table.columns.show('pk') paginate_table(table, request) bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix) + # Compile permissions list for rendering the object table + permissions = { + 'change': request.user.has_perm('ipam.change_ipaddress'), + 'delete': request.user.has_perm('ipam.delete_ipaddress'), + } + return { - 'first_available_ip': instance.get_first_available_ip(), 'table': table, + 'permissions': permissions, 'bulk_querystring': bulk_querystring, 'active_tab': 'ip-addresses', + 'first_available_ip': instance.get_first_available_ip(), 'show_available': request.GET.get('show_available', 'true') == 'true', } diff --git a/netbox/netbox/api/pagination.py b/netbox/netbox/api/pagination.py index d489ce9510..77af755ce5 100644 --- a/netbox/netbox/api/pagination.py +++ b/netbox/netbox/api/pagination.py @@ -34,23 +34,13 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): return list(queryset[self.offset:]) def get_limit(self, request): + limit = super().get_limit(request) - if self.limit_query_param: - try: - limit = int(request.query_params[self.limit_query_param]) - if limit < 0: - raise ValueError() - # Enforce maximum page size, if defined - if settings.MAX_PAGE_SIZE: - if limit == 0: - return settings.MAX_PAGE_SIZE - else: - return min(limit, settings.MAX_PAGE_SIZE) - return limit - except (KeyError, ValueError): - pass + # Enforce maximum page size + if settings.MAX_PAGE_SIZE: + limit = min(limit, settings.MAX_PAGE_SIZE) - return self.default_limit + return limit def get_next_link(self): diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index ef50edc4aa..e0f3762232 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -113,6 +113,10 @@ class ExceptionHandlingMiddleware(object): def process_exception(self, request, exception): + # Handle exceptions that occur from REST API requests + if is_api_request(request): + return rest_api_server_error(request) + # Don't catch exceptions when in debug mode if settings.DEBUG: return @@ -121,10 +125,6 @@ class ExceptionHandlingMiddleware(object): if isinstance(exception, Http404): return - # Handle exceptions that occur from REST API requests - if is_api_request(request): - return rest_api_server_error(request) - # Determine the type of exception. If it's a common issue, return a custom error page with instructions. custom_template = None if isinstance(exception, ProgrammingError): diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index b05097f3bb..f70be12a0a 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '3.0.0' +VERSION = '3.0.1' # Hostname HOSTNAME = platform.node() @@ -560,6 +560,10 @@ RQ_QUEUES = { # # Pagination +if MAX_PAGE_SIZE and PAGINATE_COUNT > MAX_PAGE_SIZE: + raise ImproperlyConfigured( + f"PAGINATE_COUNT ({PAGINATE_COUNT}) must be less than or equal to MAX_PAGE_SIZE ({MAX_PAGE_SIZE}), if set." + ) PER_PAGE_DEFAULTS = [ 25, 50, 100, 250, 500, 1000 ] diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index b00999b152..aafb2f3d83 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -181,7 +181,6 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): 'table': table, 'permissions': permissions, 'action_buttons': self.action_buttons, - 'table_config_form': TableConfigForm(table=table), 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, } context.update(self.extra_context()) diff --git a/netbox/project-static/dist/config.js b/netbox/project-static/dist/config.js index cd92937fb5..cf10225897 100644 --- a/netbox/project-static/dist/config.js +++ b/netbox/project-static/dist/config.js @@ -1,5 +1,5 @@ -(()=>{var pr=Object.create;var ae=Object.defineProperty,mr=Object.defineProperties,gr=Object.getOwnPropertyDescriptor,_r=Object.getOwnPropertyDescriptors,Er=Object.getOwnPropertyNames,hn=Object.getOwnPropertySymbols,vr=Object.getPrototypeOf,pn=Object.prototype.hasOwnProperty,yr=Object.prototype.propertyIsEnumerable;var mn=(i,t,e)=>t in i?ae(i,t,{enumerable:!0,configurable:!0,writable:!0,value:e}):i[t]=e,O=(i,t)=>{for(var e in t||(t={}))pn.call(t,e)&&mn(i,e,t[e]);if(hn)for(var e of hn(t))yr.call(t,e)&&mn(i,e,t[e]);return i},$e=(i,t)=>mr(i,_r(t)),gn=i=>ae(i,"__esModule",{value:!0});var mt=(i,t)=>()=>(t||i((t={exports:{}}).exports,t),t.exports),Tr=(i,t)=>{gn(i);for(var e in t)ae(i,e,{get:t[e],enumerable:!0})},br=(i,t,e)=>{if(t&&typeof t=="object"||typeof t=="function")for(let n of Er(t))!pn.call(i,n)&&n!=="default"&&ae(i,n,{get:()=>t[n],enumerable:!(e=gr(t,n))||e.enumerable});return i},_n=i=>br(gn(ae(i!=null?pr(vr(i)):{},"default",i&&i.__esModule&&"default"in i?{get:()=>i.default,enumerable:!0}:{value:i,enumerable:!0})),i);var yi=(i,t,e)=>new Promise((n,o)=>{var r=u=>{try{l(e.next(u))}catch(p){o(p)}},s=u=>{try{l(e.throw(u))}catch(p){o(p)}},l=u=>u.done?n(u.value):Promise.resolve(u.value).then(r,s);l((e=e.apply(i,t)).next())});var en=mt((Jo,ai)=>{(function(i,t){typeof define=="function"&&define.amd?define(t):typeof ai=="object"&&ai.exports?ai.exports=t():i.EvEmitter=t()})(typeof window!="undefined"?window:Jo,function(){"use strict";function i(){}var t=i.prototype;return t.on=function(e,n){if(!(!e||!n)){var o=this._events=this._events||{},r=o[e]=o[e]||[];return r.indexOf(n)==-1&&r.push(n),this}},t.once=function(e,n){if(!(!e||!n)){this.on(e,n);var o=this._onceEvents=this._onceEvents||{},r=o[e]=o[e]||{};return r[n]=!0,this}},t.off=function(e,n){var o=this._events&&this._events[e];if(!(!o||!o.length)){var r=o.indexOf(n);return r!=-1&&o.splice(r,1),this}},t.emitEvent=function(e,n){var o=this._events&&this._events[e];if(!(!o||!o.length)){o=o.slice(0),n=n||[];for(var r=this._onceEvents&&this._onceEvents[e],s=0;s{(function(i,t){typeof define=="function"&&define.amd?define(t):typeof li=="object"&&li.exports?li.exports=t():i.getSize=t()})(window,function(){"use strict";function t(d){var y=parseFloat(d),E=d.indexOf("%")==-1&&!isNaN(y);return E&&y}function e(){}var n=typeof console=="undefined"?e:function(d){console.error(d)},o=["paddingLeft","paddingRight","paddingTop","paddingBottom","marginLeft","marginRight","marginTop","marginBottom","borderLeftWidth","borderRightWidth","borderTopWidth","borderBottomWidth"],r=o.length;function s(){for(var d={width:0,height:0,innerWidth:0,innerHeight:0,outerWidth:0,outerHeight:0},y=0;y