diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 66f675d7c..640a3fd39 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: v2.11.7 + placeholder: v2.11.8 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index eaa9a1200..760d74298 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: v2.11.7 + placeholder: v2.11.8 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md index e1840319e..c5a500698 100644 --- a/docs/release-notes/version-2.11.md +++ b/docs/release-notes/version-2.11.md @@ -1,10 +1,16 @@ # NetBox v2.11 -## v2.11.8 (FUTURE) +## v2.11.8 (2021-07-06) ### Enhancements +* [#5503](https://github.com/netbox-community/netbox/issues/5503) - Annotate short date & time fields with their longer form +* [#6138](https://github.com/netbox-community/netbox/issues/6138) - Add an `empty` filter modifier for character fields +* [#6200](https://github.com/netbox-community/netbox/issues/6200) - Add rack reservations to global search +* [#6368](https://github.com/netbox-community/netbox/issues/6368) - Enable virtual chassis assignment during bulk import of devices * [#6620](https://github.com/netbox-community/netbox/issues/6620) - Show assigned VMs count under device role view +* [#6666](https://github.com/netbox-community/netbox/issues/6666) - Show management-only status under interface detail view +* [#6667](https://github.com/netbox-community/netbox/issues/6667) - Display VM memory as GB/TB as appropriate ### Bug Fixes @@ -12,6 +18,9 @@ * [#6637](https://github.com/netbox-community/netbox/issues/6637) - Fix group assignment in "available VLANs" link under VLAN group view * [#6640](https://github.com/netbox-community/netbox/issues/6640) - Disallow numeric values in custom text fields * [#6652](https://github.com/netbox-community/netbox/issues/6652) - Fix exception when adding components in bulk to multiple devices +* [#6676](https://github.com/netbox-community/netbox/issues/6676) - Fix device/VM counts per cluster under cluster type/group views +* [#6680](https://github.com/netbox-community/netbox/issues/6680) - Allow setting custom field values for VM interfaces on initial creation +* [#6695](https://github.com/netbox-community/netbox/issues/6695) - Fix exception when importing device type with invalid front port definition --- diff --git a/docs/rest-api/filtering.md b/docs/rest-api/filtering.md index b77513297..471beffee 100644 --- a/docs/rest-api/filtering.md +++ b/docs/rest-api/filtering.md @@ -61,25 +61,30 @@ These lookup expressions can be applied by adding a suffix to the desired field' Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions: -- `n` - not equal to (negation) -- `lt` - less than -- `lte` - less than or equal -- `gt` - greater than -- `gte` - greater than or equal +| Filter | Description | +|--------|-------------| +| `n` | Not equal to | +| `lt` | Less than | +| `lte` | Less than or equal to | +| `gt` | Greater than | +| `gte` | Greater than or equal to | ### String Fields String based (char) fields (Name, Address, etc) support these lookup expressions: -- `n` - not equal to (negation) -- `ic` - case insensitive contains -- `nic` - negated case insensitive contains -- `isw` - case insensitive starts with -- `nisw` - negated case insensitive starts with -- `iew` - case insensitive ends with -- `niew` - negated case insensitive ends with -- `ie` - case insensitive exact match -- `nie` - negated case insensitive exact match +| Filter | Description | +|--------|-------------| +| `n` | Not equal to | +| `ic` | Contains (case-insensitive) | +| `nic` | Does not contain (case-insensitive) | +| `isw` | Starts with (case-insensitive) | +| `nisw` | Does not start with (case-insensitive) | +| `iew` | Ends with (case-insensitive) | +| `niew` | Does not end with (case-insensitive) | +| `ie` | Exact match (case-insensitive) | +| `nie` | Inverse exact match (case-insensitive) | +| `empty` | Is empty (boolean) | ### Foreign Keys & Other Fields diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 6f7cc686e..4fb2e71fb 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1887,8 +1887,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm): ) rear_port = forms.ModelChoiceField( queryset=RearPortTemplate.objects.all(), - to_field_name='name', - required=False + to_field_name='name' ) class Meta: @@ -2243,6 +2242,12 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm): choices=DeviceStatusChoices, help_text='Operational status' ) + virtual_chassis = CSVModelChoiceField( + queryset=VirtualChassis.objects.all(), + to_field_name='name', + required=False, + help_text='Virtual chassis' + ) cluster = CSVModelChoiceField( queryset=Cluster.objects.all(), to_field_name='name', @@ -2253,6 +2258,10 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm): class Meta: fields = [] model = Device + help_texts = { + 'vc_position': 'Virtual chassis position', + 'vc_priority': 'Virtual chassis priority', + } def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) @@ -2291,7 +2300,8 @@ class DeviceCSVForm(BaseDeviceCSVForm): class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'location', 'rack', 'position', 'face', 'cluster', 'comments', + 'site', 'location', 'rack', 'position', 'face', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', + 'comments', ] def __init__(self, data=None, *args, **kwargs): @@ -2326,7 +2336,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm): class Meta(BaseDeviceCSVForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'parent', 'device_bay', 'cluster', 'comments', + 'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments', ] def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 6cfe52fc3..e704f74a7 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -293,19 +293,24 @@ class FrontPortTemplate(ComponentTemplateModel): def clean(self): super().clean() - # Validate rear port assignment - if self.rear_port.device_type != self.device_type: - raise ValidationError( - "Rear port ({}) must belong to the same device type".format(self.rear_port) - ) + try: - # Validate rear port position assignment - if self.rear_port_position > self.rear_port.positions: - raise ValidationError( - "Invalid rear port position ({}); rear port {} has only {} positions".format( - self.rear_port_position, self.rear_port.name, self.rear_port.positions + # Validate rear port assignment + if self.rear_port.device_type != self.device_type: + raise ValidationError( + "Rear port ({}) must belong to the same device type".format(self.rear_port) ) - ) + + # Validate rear port position assignment + if self.rear_port_position > self.rear_port.positions: + raise ValidationError( + "Invalid rear port position ({}); rear port {} has only {} positions".format( + self.rear_port_position, self.rear_port.name, self.rear_port.positions + ) + ) + + except RearPortTemplate.DoesNotExist: + pass def instantiate(self, device): if self.rear_port: diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index bbb153ee5..ca6418e58 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1028,6 +1028,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): tags = create_tags('Alpha', 'Bravo', 'Charlie') + VirtualChassis.objects.create(name='Virtual Chassis 1') + cls.form_data = { 'device_type': devicetypes[1].pk, 'device_role': deviceroles[1].pk, @@ -1053,10 +1055,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "device_role,manufacturer,device_type,status,name,site,location,rack,position,face", - "Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Location 1,Rack 1,10,front", - "Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Location 1,Rack 1,20,front", - "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front", + "device_role,manufacturer,device_type,status,name,site,location,rack,position,face,virtual_chassis,vc_position,vc_priority", + "Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Location 1,Rack 1,10,front,Virtual Chassis 1,1,10", + "Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Location 1,Rack 1,20,front,Virtual Chassis 1,2,20", + "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front,Virtual Chassis 1,3,30", ) cls.bulk_edit_data = { diff --git a/netbox/extras/apps.py b/netbox/extras/apps.py index 3201c3bb2..7500157c0 100644 --- a/netbox/extras/apps.py +++ b/netbox/extras/apps.py @@ -5,4 +5,5 @@ class ExtrasConfig(AppConfig): name = "extras" def ready(self): + import extras.lookups import extras.signals diff --git a/netbox/extras/lookups.py b/netbox/extras/lookups.py new file mode 100644 index 000000000..7197efcfc --- /dev/null +++ b/netbox/extras/lookups.py @@ -0,0 +1,17 @@ +from django.db.models import CharField, Lookup + + +class Empty(Lookup): + """ + Filter on whether a string is empty. + """ + lookup_name = 'empty' + + def as_sql(self, qn, connection): + lhs, lhs_params = self.process_lhs(qn, connection) + rhs, rhs_params = self.process_rhs(qn, connection) + params = lhs_params + rhs_params + return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params + + +CharField.register_lookup(Empty) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 58d2d857e..e292f6077 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -442,9 +442,8 @@ class JournalEntry(ChangeLoggedModel): verbose_name_plural = 'journal entries' def __str__(self): - created_date = timezone.localdate(self.created) - created_time = timezone.localtime(self.created) - return f"{date_format(created_date)} - {time_format(created_time)} ({self.get_kind_display()})" + created = timezone.localtime(self.created) + return f"{date_format(created, format='SHORT_DATETIME_FORMAT')} ({self.get_kind_display()})" def get_absolute_url(self): return reverse('extras:journalentry', args=[self.pk]) diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index 1da51fe76..31e56fb1f 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -4,12 +4,12 @@ from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNet from circuits.models import Circuit, ProviderNetwork, Provider from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable from dcim.filtersets import ( - CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, LocationFilterSet, - SiteFilterSet, VirtualChassisFilterSet, + CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet, + LocationFilterSet, SiteFilterSet, VirtualChassisFilterSet, ) -from dcim.models import Cable, Device, DeviceType, PowerFeed, Rack, Location, Site, VirtualChassis +from dcim.models import Cable, Device, DeviceType, Location, PowerFeed, Rack, RackReservation, Site, VirtualChassis from dcim.tables import ( - CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, LocationTable, SiteTable, + CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, RackReservationTable, LocationTable, SiteTable, VirtualChassisTable, ) from ipam.filtersets import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet @@ -61,6 +61,12 @@ SEARCH_TYPES = OrderedDict(( 'table': RackTable, 'url': 'dcim:rack_list', }), + ('rackreservation', { + 'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'), + 'filterset': RackReservationFilterSet, + 'table': RackReservationTable, + 'url': 'dcim:rackreservation_list', + }), ('location', { 'queryset': Location.objects.add_related_count( Location.objects.all(), diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index aa9e15385..791c21d19 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -89,13 +89,13 @@ class BaseFilterSet(django_filters.FilterSet): filters.MultiValueNumberFilter, filters.MultiValueTimeFilter )): - lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP + return FILTER_NUMERIC_BASED_LOOKUP_MAP elif isinstance(existing_filter, ( filters.TreeNodeMultipleChoiceFilter, )): # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression - lookup_map = FILTER_TREENODE_NEGATION_LOOKUP_MAP + return FILTER_TREENODE_NEGATION_LOOKUP_MAP elif isinstance(existing_filter, ( django_filters.ModelChoiceFilter, @@ -103,7 +103,7 @@ class BaseFilterSet(django_filters.FilterSet): TagFilter )) or existing_filter.extra.get('choices'): # These filter types support only negation - lookup_map = FILTER_NEGATION_LOOKUP_MAP + return FILTER_NEGATION_LOOKUP_MAP elif isinstance(existing_filter, ( django_filters.filters.CharFilter, @@ -111,12 +111,9 @@ class BaseFilterSet(django_filters.FilterSet): filters.MultiValueCharFilter, filters.MultiValueMACAddressFilter )): - lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP + return FILTER_CHAR_BASED_LOOKUP_MAP - else: - lookup_map = None - - return lookup_map + return None @classmethod def get_filters(cls): diff --git a/netbox/netbox/forms.py b/netbox/netbox/forms.py index a032229b2..b5d68c1fc 100644 --- a/netbox/netbox/forms.py +++ b/netbox/netbox/forms.py @@ -11,12 +11,13 @@ OBJ_TYPE_CHOICES = ( ('DCIM', ( ('site', 'Sites'), ('rack', 'Racks'), + ('rackreservation', 'Rack reservations'), ('location', 'Locations'), ('devicetype', 'Device Types'), ('device', 'Devices'), - ('virtualchassis', 'Virtual Chassis'), + ('virtualchassis', 'Virtual chassis'), ('cable', 'Cables'), - ('powerfeed', 'Power Feeds'), + ('powerfeed', 'Power feeds'), )), ('IPAM', ( ('vrf', 'VRFs'), diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index b00d07ec2..9a78062fb 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -18,7 +18,7 @@ from django_tables2.export import TableExport from extras.models import ExportTemplate from utilities.error_handlers import handle_protectederror -from utilities.exceptions import AbortTransaction +from utilities.exceptions import AbortTransaction, PermissionsViolation from utilities.forms import ( BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, ImportForm, TableConfigForm, restrict_form_fields, ) @@ -267,7 +267,8 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): obj = form.save() # Check that the new object conforms with any assigned object-level permissions - self.queryset.get(pk=obj.pk) + if not self.queryset.filter(pk=obj.pk).first(): + raise PermissionsViolation() msg = '{} {}'.format( 'Created' if object_created else 'Modified', @@ -295,7 +296,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): else: return redirect(self.get_return_url(request, obj)) - except ObjectDoesNotExist: + except PermissionsViolation: msg = "Object save failed due to object-level permissions violation" logger.debug(msg) form.add_error(None, msg) @@ -457,7 +458,7 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Enforce object-level permissions if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): - raise ObjectDoesNotExist + raise PermissionsViolation # If we make it to this point, validation has succeeded on all new objects. msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural) @@ -471,7 +472,7 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): except IntegrityError: pass - except ObjectDoesNotExist: + except PermissionsViolation: msg = "Object creation failed due to object-level permissions violation" logger.debug(msg) form.add_error(None, msg) @@ -542,7 +543,8 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): obj = model_form.save() # Enforce object-level permissions - self.queryset.get(pk=obj.pk) + if not self.queryset.filter(pk=obj.pk).first(): + raise PermissionsViolation() logger.debug(f"Created {obj} (PK: {obj.pk})") @@ -578,7 +580,7 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): except AbortTransaction: pass - except ObjectDoesNotExist: + except PermissionsViolation: msg = "Object creation failed due to object-level permissions violation" logger.debug(msg) form.add_error(None, msg) @@ -689,7 +691,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Enforce object-level permissions if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): - raise ObjectDoesNotExist + raise PermissionsViolation # Compile a table containing the imported objects obj_table = self.table(new_objs) @@ -707,7 +709,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): except ValidationError: pass - except ObjectDoesNotExist: + except PermissionsViolation: msg = "Object import failed due to object-level permissions violation" logger.debug(msg) form.add_error(None, msg) @@ -822,7 +824,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Enforce object-level permissions if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(updated_objects): - raise ObjectDoesNotExist + raise PermissionsViolation if updated_objects: msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural) @@ -834,7 +836,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): except ValidationError as e: messages.error(self.request, "{} failed validation: {}".format(obj, e)) - except ObjectDoesNotExist: + except PermissionsViolation: msg = "Object update failed due to object-level permissions violation" logger.debug(msg) form.add_error(None, msg) @@ -929,7 +931,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): # Enforce constrained permissions if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects): - raise ObjectDoesNotExist + raise PermissionsViolation messages.success(request, "Renamed {} {}".format( len(selected_objects), @@ -937,7 +939,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): )) return redirect(self.get_return_url(request)) - except ObjectDoesNotExist: + except PermissionsViolation: msg = "Object update failed due to object-level permissions violation" logger.debug(msg) form.add_error(None, msg) @@ -1142,7 +1144,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View # Enforce object-level permissions if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): - raise ObjectDoesNotExist + raise PermissionsViolation messages.success(request, "Added {} {}".format( len(new_components), self.queryset.model._meta.verbose_name_plural @@ -1150,7 +1152,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View # Return the newly created objects so overridden post methods can use the data as needed. return new_objs - except ObjectDoesNotExist: + except PermissionsViolation: msg = "Component creation failed due to object-level permissions violation" logger.debug(msg) form.add_error(None, msg) @@ -1227,12 +1229,12 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, # Enforce object-level permissions if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components): - raise ObjectDoesNotExist + raise PermissionsViolation except IntegrityError: pass - except ObjectDoesNotExist: + except PermissionsViolation: msg = "Component creation failed due to object-level permissions violation" logger.debug(msg) form.add_error(None, msg) diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index bcc3df141..506321fd5 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -1,5 +1,6 @@ {# Base layout for the core NetBox UI w/navbar and page content #} {% extends 'base/base.html' %} +{% load helpers %} {% load nav %} {% load search_options %} {% load static %} @@ -115,7 +116,7 @@
- Run: {{ result.created }} + Run: {{ result.created|annotated_date }} {% if result.completed %} Duration: {{ result.duration }} {% else %} diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index d271723d5..b06520c94 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -30,7 +30,7 @@
- Run: {{ result.created }} + Run: {{ result.created|annotated_date }} {% if result.completed %} Duration: {{ result.duration }} {% else %} diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index 442a4617f..d3d11c31f 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -72,8 +72,8 @@ {% block content %}
- Created {{ object.created }} · - Updated {{ object.last_updated|timesince }} ago + Created {{ object.created|annotated_date }} · + Updated {{ object.last_updated|timesince }} ago {{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }}
diff --git a/netbox/templates/inc/image_attachments.html b/netbox/templates/inc/image_attachments.html index c148887a0..5ec47af7e 100644 --- a/netbox/templates/inc/image_attachments.html +++ b/netbox/templates/inc/image_attachments.html @@ -1,3 +1,4 @@ +{% load helpers %} {% if images %}{{ attachment.size|filesizeformat }} | -{{ attachment.created }} | +{{ attachment.created|annotated_date }} |