diff --git a/taccsite_cms/contrib/_docs/how-to-conditionally-render-child-plugins.md b/taccsite_cms/contrib/_docs/how-to-conditionally-render-child-plugins.md new file mode 100644 index 000000000..db314ed32 --- /dev/null +++ b/taccsite_cms/contrib/_docs/how-to-conditionally-render-child-plugins.md @@ -0,0 +1,11 @@ +# How to Conditionally Render Child Plugins + +```handlebars + {% for plugin_instance in instance.child_plugin_instances %} + {% if plugin_instance.plugin_type == 'LinkPlugin' %} + > + + + {% endif %} + {% endfor %} +``` diff --git a/taccsite_cms/contrib/_docs/how-to-extend-django-cms-plugin.md b/taccsite_cms/contrib/_docs/how-to-extend-django-cms-plugin.md new file mode 100644 index 000000000..f93e07d4e --- /dev/null +++ b/taccsite_cms/contrib/_docs/how-to-extend-django-cms-plugin.md @@ -0,0 +1,66 @@ +# How To Extend a `djangocms-___` Plugin + +These example codes extend the [`djangocms-link` plugin](https://github.com/django-cms/djangocms-link/tree/3.0.0/djangocms_link). + +`.../models.py`: + +```python +from djangocms_link.models import AbstractLink + +class Taccsite______(AbstractLink): + """ + Components > "Article List" Model + https://confluence.tacc.utexas.edu/x/OIAjCQ + """ + # ___ = ___ + + class Meta: + abstract = False +``` + +`.../cms_plugins.py`: + +```python +from djangocms_link.cms_plugins import LinkPlugin + +from .models import ______Preview + +class ______Plugin(LinkPlugin): + module = 'TACC Site' + model = Taccsite______ + name = _('______') + render_template = 'static_article_preview.html' + def get_render_template(self, context, instance, placeholder): + return self.render_template + + fieldsets = [ + (_('Link'), { + 'fields': ( + ('external_link', 'internal_link'), + ('anchor', 'target'), + ) + }), + ] + + # Render + def render(self, context, instance, placeholder): + context = super().render(context, instance, placeholder) + request = context['request'] + + context.update({ + 'link_url': instance.get_link(), + 'link_text': instance.name, + 'link_target': instance.target + }) + return context +``` + +`.../templates/______.py`: + +```handlebars + + + {{ link_text }} + +``` diff --git a/taccsite_cms/contrib/_docs/how-to-handle-non-nullable-default-value.md b/taccsite_cms/contrib/_docs/how-to-handle-non-nullable-default-value.md new file mode 100644 index 000000000..fb558ce69 --- /dev/null +++ b/taccsite_cms/contrib/_docs/how-to-handle-non-nullable-default-value.md @@ -0,0 +1,62 @@ +# How to Handle "Non-Nullable" "Default Value" + +## Sample Error + +```text +You are trying to add a non-nullable field '...' +to choice without a default; we can't do that +(the database needs something to populate existing rows). +Please select a fix: + 1) Provide a one-off default now (will be set on all existing rows) + 2) Quit, and let me add a default in models.py +Select an option: +``` + +## Explanations + +- (blog post) [What do you do when 'makemigrations' is telling you About your Lack of Default Value](https://chrisbartos.com/articles/what-do-you-do-when-makemigrations-is-telling-you-about-your-lack-of-default-value/) +- (video) [You are trying to add a non-nullable field ' ' to ' ' without a default; we can't do that](https://www.youtube.com/watch?v=NgaTUEijQSQ) + +## Solutions + +### For `cmsplugin_ptr` + +1. ☑ Select option 1), then see: + - [Follow-Up Error](#follow-up-error) + - [Notes ▸ `cmsplugin_ptr`](#cmsplugin_ptr) + +### For Other Fields + +1. ⚠ Select option 1) and hope for the best. +2. ☑ Select option 2) and provide a sensible default (_not_ `None` a.k.a. null). +3. ⚠ (blog post) (hack) [Add A Migration For A Non-Null Foreignkey Field In Django](https://jaketrent.com/post/add-migration-nonnull-foreignkey-field-django) + +## Follow-Up Error + +If you allowed Null to be set as default, then you may have this new error: + +```text +django.db.utils.IntegrityError: column "..." contains null values +``` + +Solutions: + +1. [delete _relevant_ migration files and rebuild migrations](https://stackoverflow.com/a/37244199/11817077) +2. [delete _all_ migration files and rebuild migrations](https://stackoverflow.com/a/37242930/11817077) + +## Notes + +### `cmsplugin_ptr` + + If the field is `cmsplugin_ptr` then know that + + - [it is a database relationship field managed automatically by Django](https://github.com/nephila/djangocms-blog/issues/316#issuecomment-242292787), + - you may see it in workarounds for other plugins ([source a](https://github.com/django-cms/djangocms-link/blob/3.0.0/djangocms_link/models.py#L125), [source b](https://github.com/django-cms/djangocms-picture/blob/3.0.0/djangocms_picture/models.py#L208)), + - you should __not__ add or overwrite it unless you know what you are doing. + + _W. Bomar learned everything in the intitial version of this document after trying to overwrite `cmsplugin_ptr` while extending its model from [source a](https://github.com/django-cms/djangocms-link/blob/3.0.0/djangocms_link/models.py#L125). His solution was [delete _all_ migration files and rebuild migrations](https://stackoverflow.com/a/37242930/11817077)._ + +## Appendix + +- [Django CMS ▸ How to create Plugins ▸ Handling Relations](https://docs.django-cms.org/en/release-3.7.x/how_to/custom_plugins.html#handling-relations) +- [[BUG] Plugins with models that don't directly inherit from CMSPlugin or an abstract model cannot be copied](https://github.com/django-cms/django-cms/issues/6987) diff --git a/taccsite_cms/contrib/_docs/how-to-override-validation-error-from-parent-model.md b/taccsite_cms/contrib/_docs/how-to-override-validation-error-from-parent-model.md new file mode 100644 index 000000000..a9ccbfc9f --- /dev/null +++ b/taccsite_cms/contrib/_docs/how-to-override-validation-error-from-parent-model.md @@ -0,0 +1,67 @@ +# How To Override `ValidationError()` from Parent Model + +Intercept Error(s): + +```python +from django.core.exceptions import ValidationError + +from djangocms_Xxx.models import AbstractXxx + +from taccsite_cms.contrib.helpers import ( + get_indices_that_start_with +) + +class OurModelThatUsesXxx(AbstractXxx): + # Validate + def clean(self): + # Bypass irrelevant parent validation + try: + super().clean() + except ValidationError as err: + # Intercept single-field errors + if hasattr(err, 'error_list'): + for i in range(len(err.error_list)): + # SEE: "Find Error(s)" + # ... + # Skip error + del err.error_list[i] + # Replace error + # SEE: https://docs.djangoproject.com/en/2.2/ref/forms/validation/#raising-validationerror + + # Intercept multi-field errors + if hasattr(err, 'error_dict'): + for field, errors in err.message_dict.items(): + # SEE: "Find Error(s)" + # ... + # Skip error + del err.error_dict[field] + # Replace error + # SEE: https://docs.djangoproject.com/en/2.2/ref/forms/validation/#raising-validationerror + + # NOTE: The conditional `pass` is only to skip multi-field errors; + # single-field error skipping is unaffected by this logic; + # so it seems safe to always include this logic block + if len(err.messages) == 0: + pass + else: + raise err +``` + +Handle Error(s): + +```python +# SEE: "Find Error(s)" +# ... + + # Catch known static error + if 'Known static error string' in error: + # ... + + # Catch known dynamic error + indices_to_catch = get_indices_that_start_with( + 'Known dynamic error string that starts with same text', + errors + ) + for i in indices_to_catch: + # ... +``` diff --git a/taccsite_cms/contrib/_docs/taccsite_static_article.md b/taccsite_cms/contrib/_docs/taccsite_static_article.md new file mode 100644 index 000000000..e69d9e300 --- /dev/null +++ b/taccsite_cms/contrib/_docs/taccsite_static_article.md @@ -0,0 +1,66 @@ +# Static Article Plugins + +## Intention + +Support static addition of news articles that originate from a Core news site. + +A [dynamic solution that pulls form the Core news site](https://github.com/TACC/Core-CMS/issues/69) is preferable. + +But this is not available due to constrainst of architecture, time, or ability. + +## Architecture + +### (Currently) Add Image via Child Plugin Instead of Via Fields + +Instead, the image fields should be in the plugin, __not__ via a child plugin, but a solution has not yet been implemented. + +#### Hope for the Future + +The `AbstractLink` model was successfully extended. + +See: + - [./how-to-extend-django-cms-plugin.md](./how-to-extend-django-cms-plugin.md) + - [../taccsite_static_article_preview](../taccsite_static_article_preview) + - [../taccsite_static_article_list](../taccsite_static_article_list) + +#### Failed Attempt + +1. Build model so it extends `AbstractPicture` from `djangocms-picture`. +2. Tweak model to sweep bugs under the rug. +3. Quit when he was unable to resolve the error, + `TaccsiteStaticNewsArticlePreview has no field named 'cmsplugin_ptr_id'` + upon saving a plugin instance. +4. Learn: + - [one should not try to reduce `AbstractPicture`](https://stackoverflow.com/a/3674714/11817077) + - [one should not subclass a subclass of `CMSPlugin`](https://github.com/django-cms/django-cms/blob/3.7.4/cms/models/pluginmodel.py#L104) + +#### Abandoned Code + +```python +from djangocms_picture.models import AbstractPicture + +# To allow user to not set image +# FAQ: Emptying the clean() method avoids picture validation +# SEE: https://github.com/django-cms/djangocms-picture/blob/3.0.0/djangocms_picture/models.py#L278 +def skip_image_validation(): + pass + +class TaccsiteStaticNewsArticlePreview(AbstractPicture): + # + # … + # + + # Remove error-prone attribute from parent class + # FAQ: Avoid error when running `makemigrations`: + # "You are trying to add a non-nullable field 'cmsplugin_ptr' […]" + # SEE: https://github.com/django-cms/djangocms-picture/blob/3.0.0/djangocms_picture/models.py#L212 + # SEE: https://github.com/django-cms/djangocms-picture/blob/3.0.0/djangocms_picture/models.py#L234 + cmsplugin_ptr = None + + class Meta: + abstract = False + + # Validate + def clean(self): + skip_image_validation() +``` diff --git a/taccsite_cms/contrib/bootstrap4_djangocms_link/cms_plugins.py b/taccsite_cms/contrib/bootstrap4_djangocms_link/cms_plugins.py index 9de684a61..98ab52d07 100644 --- a/taccsite_cms/contrib/bootstrap4_djangocms_link/cms_plugins.py +++ b/taccsite_cms/contrib/bootstrap4_djangocms_link/cms_plugins.py @@ -1,4 +1,4 @@ -# Reregister unregistered LinkPlugin without uninstalling Bootstrap4's +# Re-register unregistered LinkPlugin without uninstalling Bootstrap4's # FAQ: A Bootstrap link is undesirable but may be used by migrated legacy sites # TODO: Drop try/except & load non-standard plugin set for migrated legacy sites # FAQ: If we can import both plugins, then re-register LinkPlugin @@ -18,7 +18,7 @@ # SEE: https://github.com/django-cms/djangocms-link/issues/163 plugin_pool.register_plugin(LinkPlugin) -# CAVEAT: If import statement fails for reason other than Bootstrap presence, +# CAVEAT: If import statement fails for reason other than Bootstrap absence, # then that failure, and the failure of this plugin, is silent except ImportError: pass diff --git a/taccsite_cms/contrib/bootstrap4_djangocms_picture/cms_plugins.py b/taccsite_cms/contrib/bootstrap4_djangocms_picture/cms_plugins.py index 939bb5069..31bcae84e 100644 --- a/taccsite_cms/contrib/bootstrap4_djangocms_picture/cms_plugins.py +++ b/taccsite_cms/contrib/bootstrap4_djangocms_picture/cms_plugins.py @@ -1,5 +1,6 @@ -# Reregister unregistered PicturePlugin without uninstalling Bootstrap4's +# Re-register unregistered PicturePlugin without uninstalling Bootstrap4's # FAQ: A Bootstrap picture has superfluous options that are not always desirable +# TODO: Drop try/except & load non-standard plugin set for migrated legacy sites # FAQ: If we can import both plugins, then re-register PicturePlugin # (because Bootstrap4Picture unregistered PicturePlugin) try: diff --git a/taccsite_cms/contrib/helpers.py b/taccsite_cms/contrib/helpers.py index 25b9cbf03..9c6d21343 100644 --- a/taccsite_cms/contrib/helpers.py +++ b/taccsite_cms/contrib/helpers.py @@ -1,7 +1,254 @@ -# SEE: https://github.com/django-cms/djangocms-bootstrap4/blob/master/djangocms_bootstrap4/helpers.py -def concat_classnames(classes): +# Get Django `models.CharField` `choices` +def get_choices(choice_dict): + """Get a sequence for a Django model field choices from a dictionary. + + :param Dict[str, Dict[str, str]] dictionary: choice as key for dictionary of classnames and descriptions + :return: a sequence for django.db.models.CharField.choices + :rtype: List[Tuple[str, str], ...] """ - Concatenates a list of classes (without failing on None) + choices = [] + + for key, data in choice_dict.items(): + choice = (key, data['description']) + choices.append(choice) + + return choices + + + +# Filter Django `models.CharField` `choices` +# SEE: get_choices +def filter_choices_by_prefix(choices, prefix): + """Reduce sequence of choices to items whose values begin with given string + + :param List[Tuple[str, str], ...] choices: the sequence to filter + :param str prefix: the starting text required of an item value to retain it + :returns: a sequence for django.db.models.CharField.choices + :rtype: List[Tuple[str, str], ...] """ + new_choices = [] + + for choice in choices: + should_keep = choice[0].startswith(prefix) + if should_keep: + new_choices.append(choice) + + return new_choices + + + +# Concatenate a list of CSS classes +# SEE: https://github.com/django-cms/djangocms-bootstrap4/blob/master/djangocms_bootstrap4/helpers.py +def concat_classnames(classes): + """Concatenate a list of classname strings (without failing on None)""" # SEE: https://stackoverflow.com/a/20271297/11817077 return ' '.join(_class for _class in classes if _class) + + + +# Create a list clone that has another list shoved into it +# SEE: https://newbedev.com/how-to-insert-multiple-elements-into-a-list +def insert_at_position(position, list, list_to_insert): + """Insert list at position within another list + + :returns: New list + """ + return list[:position] + list_to_insert + list[position:] + + + +# Get the date from a list that is nearest +# SEE: https://stackoverflow.com/a/32237949/11817077 +def get_nearest(items, pivot): + """Get nearest date (or other arithmatic value) + + :returns: The item value nearest the given "pivot" value + """ + return min(items, key=lambda x: abs(x - pivot)) + + + +# Get list of indicies of items that start with text +# SEE: https://stackoverflow.com/a/67393343/11817077 +def get_indices_that_start_with(text, list): + """ + Get a list of indices of list elements that starts with given text + + :rtype: list + """ + return [i for i in range(len(list)) if list[i].startswith(text)] + + + +# Populate class attribute of plugin instances +def add_classname_to_instances(classname, plugin_instances): + """Add class names to class attribute of plugin instances""" + for instance in plugin_instances: + # A plugin must not have any class set + if not hasattr(instance.attributes, 'class'): + instance.attributes['class'] = '' + + # The class should occur before any CMS or user classes + # FAQ: This keeps plugin author classes together + instance.attributes['class'] = instance.attributes['class'] + classname + + + +# Get date nearest today + +from datetime import date + +# HELP: Can this logic be less verbose? +# HELP: Is the `preferred_time_period` parameter effectual? +def which_date_is_nearest_today(date_a, date_b, preferred_time_period): + """ + Returns whether each date is today or nearest today, and whether nearest date is past or today or future. + + Only two dates are supported. You may prefer 'future' or 'past' date(s). + + If both dates are the same date, then both are reported as True. + + :param datetime date_a: a date "A" to compare + :param datetime date_b: a date "B" to compare + :param str preferred_time_period: whether to prefer 'future' or 'past' dates + + :returns: + A tuple of tuples: + (( + ``boolean`` of whether ``date_a`` is nearest, + ``string`` of ``date_a`` time period ``past``/``today``/``future`` + ), + ( + ``boolean`` of whether ``date_b`` is nearest, + ``string`` of ``date_b`` time period ``past``/``today``/``future`` + )), + :rtype: tuple + """ + today = date.today() + is_a = False + is_b = False + a_time_period = 'today' + b_time_period = 'today' + + # Match preferred time + + if today in {date_a, date_b}: + is_a = True + is_b = True + a_time_period = 'today' + b_time_period = 'today' + + elif preferred_time_period == 'future': + is_a = date_a and date_a >= today + is_b = date_b and date_b >= today + if is_a: a_time_period = 'future' + if is_b: b_time_period = 'future' + if not is_a and not is_b: + is_a = date_a and date_a < today + is_b = date_b and date_b < today + if is_a: a_time_period = 'past' + if is_b: b_time_period = 'past' + + elif preferred_time_period == 'past': + is_a = date_a and date_a < today + is_b = date_b and date_b < today + if is_a: a_time_period = 'past' + if is_b: b_time_period = 'past' + if not is_a and not is_b: + is_a = date_a and date_a >= today + is_b = date_b and date_b >= today + if is_a: a_time_period = 'future' + if is_b: b_time_period = 'future' + + # Show nearest date + if is_a and is_b and date_a != date_b: + nearest_date = get_nearest((date_a, date_b), today) + + if date_a == nearest_date: + is_b = False + if date_b == nearest_date: + is_a = False + + return ((is_a, a_time_period), (is_b, b_time_period)) + + + +# Allow plugins to set max number of nested children + +from django.shortcuts import render + +# SEE: https://github.com/django-cms/django-cms/issues/5102#issuecomment-597150141 +class AbstractMaxChildrenPlugin(): + """ + Abstract extension of `CMSPluginBase` that allows setting maximum amount of nested/child plugins. + + Usage: + 1. Extend this class, + after extending `CMSPluginBase` or a class that extends `CMSPluginBase`. + 2. Set `max_children` to desired limit. + """ + + max_children = None + + def add_view(self,request, form_url='', extra_context=None): + + if self.max_children: + # FAQ: Placeholders do not have a parent, only plugins do + if self._cms_initial_attributes['parent']: + num_allowed = len([v for v in self._cms_initial_attributes['parent'].get_children() if v.get_plugin_instance()[0] is not None]) + else: + num_allowed = len([v for v in self.placeholder.get_plugins() if v.get_plugin_instance()[0] is not None and v.get_plugin_name() == self.name]) + + if num_allowed >= self.max_children: + return render(request , "path/to/your/max_reached_template.html", { + 'max_children': self.max_children, + }) + return super(AbstractMaxChildrenPlugin, self).add_view(request, form_url, extra_context) + + + +# Tweak validation on Django CMS `AbstractLink` for TACC + +from cms.models.pluginmodel import CMSPlugin + +from django.core.exceptions import ValidationError +from django.utils.translation import gettext_lazy as _ + +# SEE: https://github.com/django-cms/djangocms-link/blob/3.0.0/djangocms_link/models.py#L48 +def clean_for_abstract_link(model, self): + """ + Intercept and manipulate validation on `AbstractLink` so that it suits TACC's minimal subclassing of it. (To catch only parent validation errors, not custom ones, run this before any custom validation.) + + Usage: + ``` + from taccsite_cms.contrib.helpers import clean_for_abstract_link + + # Validate + def clean(self): + clean_for_abstract_link(__class__, self) + + ... + ``` + """ + + # Bypass irrelevant parent validation + # SEE: ./_docs/how-to-override-validation-error-from-parent-model.md + try: + super(model, self).clean() + except ValidationError as err: + # Intercept multi-field errors + if hasattr(err, 'error_dict'): + for field, errors in err.message_dict.items(): + # Reduce verbosity of original error message + # FAQ: Original error message assumes more fields exist + indices = get_indices_that_start_with( + 'Only one of ', errors + ) + for i in indices: + err.error_dict[field] = ValidationError( + _('Only one of External link or Internal link may be given.'), code='invalid') + + if len(err.messages) == 0: + pass + else: + raise err diff --git a/taccsite_cms/contrib/taccsite_blockquote/cms_plugins.py b/taccsite_cms/contrib/taccsite_blockquote/cms_plugins.py index 27e44ceb9..f725e2d63 100644 --- a/taccsite_cms/contrib/taccsite_blockquote/cms_plugins.py +++ b/taccsite_cms/contrib/taccsite_blockquote/cms_plugins.py @@ -3,8 +3,7 @@ from django.utils.translation import gettext_lazy as _ from taccsite_cms.contrib.helpers import concat_classnames - -from taccsite_cms.contrib.taccsite_offset.models import get_direction_classname +from taccsite_cms.contrib.taccsite_offset.cms_plugins import get_direction_classname from .models import TaccsiteBlockquote @@ -19,8 +18,9 @@ class TaccsiteBlockquotePlugin(CMSPluginBase): name = _('Blockquote') render_template = 'blockquote.html' - cache = False + cache = True text_enabled = True + allow_children = False fieldsets = [ (None, { @@ -51,7 +51,6 @@ class TaccsiteBlockquotePlugin(CMSPluginBase): ] # Render - def render(self, context, instance, placeholder): context = super().render(context, instance, placeholder) request = context['request'] diff --git a/taccsite_cms/contrib/taccsite_blockquote/templates/blockquote.html b/taccsite_cms/contrib/taccsite_blockquote/templates/blockquote.html index 9e66c7622..71fea29cd 100644 --- a/taccsite_cms/contrib/taccsite_blockquote/templates/blockquote.html +++ b/taccsite_cms/contrib/taccsite_blockquote/templates/blockquote.html @@ -1,4 +1,4 @@ -
+

{{ instance.text }}

diff --git a/taccsite_cms/contrib/taccsite_offset/cms_plugins.py b/taccsite_cms/contrib/taccsite_offset/cms_plugins.py index a29b4f558..607cb05d8 100644 --- a/taccsite_cms/contrib/taccsite_offset/cms_plugins.py +++ b/taccsite_cms/contrib/taccsite_offset/cms_plugins.py @@ -5,7 +5,17 @@ from taccsite_cms.contrib.helpers import concat_classnames -from .models import TaccsiteOffset, get_direction_classname +from .models import TaccsiteOffset, DIRECTION_DICT + +# Helpers + +# FAQ: This exists to retireve classnames via consistently-named functions +# SEE: taccsite_cms.contrib.taccsite_static_article_list.cms_plugins +def get_direction_classname(value): + """Get direction class based on value.""" + return DIRECTION_DICT.get(value, {}).get('classname') + +# Plugins @plugin_pool.register_plugin class TaccsiteOffsetPlugin(CMSPluginBase): @@ -18,7 +28,7 @@ class TaccsiteOffsetPlugin(CMSPluginBase): name = _('Offset Content') render_template = 'offset.html' - cache = False + cache = True text_enabled = False allow_children = True @@ -37,7 +47,6 @@ class TaccsiteOffsetPlugin(CMSPluginBase): ] # Render - def render(self, context, instance, placeholder): context = super().render(context, instance, placeholder) request = context['request'] diff --git a/taccsite_cms/contrib/taccsite_offset/models.py b/taccsite_cms/contrib/taccsite_offset/models.py index 3904e601a..ccf4abc6d 100644 --- a/taccsite_cms/contrib/taccsite_offset/models.py +++ b/taccsite_cms/contrib/taccsite_offset/models.py @@ -5,30 +5,24 @@ from djangocms_attributes_field import fields - - -# Constants - -DIRECTION_CHOICES = ( - ('left', _('Left')), - # ('center', _('Center')), # GH-66: Support centered offset content - ('right', _('Right')), -) +from taccsite_cms.contrib.helpers import get_choices -# Helpers - -def get_direction_classname(offset): - """Get offset content class based on standard offset value.""" - - # HELP: Should we limit input by coupling this map to DIRECTION_CHOICES? - switcher = { - 'right': 'o-offset-content--right', - 'left': 'o-offset-content--left' - } +# Constants - return switcher.get(offset, '') +# TODO: Consider using an Enum (and an Abstract Enum with `get_choices` method) +DIRECTION_DICT = { + 'left': { + 'classname': 'o-offset-content--left', + 'description': 'Left', + }, + 'right': { + 'classname': 'o-offset-content--right', + 'description': 'Right', + }, +} +DIRECTION_CHOICES = get_choices(DIRECTION_DICT) diff --git a/taccsite_cms/contrib/taccsite_offset/templates/offset.html b/taccsite_cms/contrib/taccsite_offset/templates/offset.html index 98edf0c42..583ca7ad0 100644 --- a/taccsite_cms/contrib/taccsite_offset/templates/offset.html +++ b/taccsite_cms/contrib/taccsite_offset/templates/offset.html @@ -1,7 +1,7 @@ {% load cms_tags %} -