From 32c02f2a0eaf66e4744f9904eb2a743b87300257 Mon Sep 17 00:00:00 2001 From: Sergei Maertens Date: Sun, 3 Jul 2016 16:19:06 +0200 Subject: [PATCH] Fixed #5908 -- Added {% resetcycle %} template tag. Thanks to Simon Litchfield for the report, Uninen for the initial patch, akaihola, jamesp, b.schube, and Florian Appoloner for subsequent patches, tests, and documentation. --- django/template/defaulttags.py | 45 +++++++++ docs/ref/templates/builtins.txt | 54 +++++++++++ docs/releases/1.11.txt | 3 + .../syntax_tests/test_resetcycle.py | 95 +++++++++++++++++++ 4 files changed, 197 insertions(+) create mode 100644 tests/template_tests/syntax_tests/test_resetcycle.py diff --git a/django/template/defaulttags.py b/django/template/defaulttags.py index bb6e8bf4c2..b2681e8bb7 100644 --- a/django/template/defaulttags.py +++ b/django/template/defaulttags.py @@ -88,6 +88,12 @@ class CycleNode(Node): return '' return render_value_in_context(value, context) + def reset(self, context): + """ + Reset the cycle iteration back to the beginning. + """ + context.render_context[self] = itertools_cycle(self.cyclevars) + class DebugNode(Node): def render(self, context): @@ -387,6 +393,15 @@ class NowNode(Node): return formatted +class ResetCycleNode(Node): + def __init__(self, node): + self.node = node + + def render(self, context): + self.node.reset(context) + return '' + + class SpacelessNode(Node): def __init__(self, nodelist): self.nodelist = nodelist @@ -582,6 +597,9 @@ def cycle(parser, token): # that names are only unique within each template (as opposed to using # a global variable, which would make cycle names have to be unique across # *all* templates. + # + # It keeps the last node in the parser to be able to reset it with + # {% resetcycle %}. args = token.split_contents() @@ -621,6 +639,7 @@ def cycle(parser, token): else: values = [parser.compile_filter(arg) for arg in args[1:]] node = CycleNode(values) + parser._last_cycle_node = node return node @@ -1216,6 +1235,32 @@ def regroup(parser, token): return RegroupNode(target, expression, var_name) +@register.tag +def resetcycle(parser, token): + """ + Resets a cycle tag. + + If an argument is given, resets the last rendered cycle tag whose name + matches the argument, else resets the last rendered cycle tag (named or + unnamed). + """ + args = token.split_contents() + + if len(args) > 2: + raise TemplateSyntaxError("%r tag accepts at most one argument." % args[0]) + + if len(args) == 2: + name = args[1] + try: + return ResetCycleNode(parser._named_cycle_nodes[name]) + except (AttributeError, KeyError): + raise TemplateSyntaxError("Named cycle '%s' does not exist." % name) + try: + return ResetCycleNode(parser._last_cycle_node) + except AttributeError: + raise TemplateSyntaxError("No cycles in template.") + + @register.tag def spaceless(parser, token): """ diff --git a/docs/ref/templates/builtins.txt b/docs/ref/templates/builtins.txt index 9a5bd4abba..61c36f47fb 100644 --- a/docs/ref/templates/builtins.txt +++ b/docs/ref/templates/builtins.txt @@ -185,6 +185,9 @@ call to ``{% cycle %}`` doesn't specify ``silent``:: {% cycle 'row1' 'row2' as rowcolors silent %} {% cycle rowcolors %} +You can use the :ttag:`resetcycle` tag to make a ``{% cycle %}`` tag restart +from its first value when it's next encountered. + .. templatetag:: debug ``debug`` @@ -994,6 +997,57 @@ attribute, allowing you to group on the display string rather than the ``{{ country.grouper }}`` will now display the value fields from the ``choices`` set rather than the keys. +.. templatetag:: resetcycle + +``resetcycle`` +-------------- + +.. versionadded:: 1.11 + +Resets a previous `cycle`_ so that it restarts from its first item at its next +encounter. Without arguments, ``{% resetcycle %}`` will reset the last +``{% cycle %}`` defined in the template. + +Example usage:: + + {% for coach in coach_list %} +

{{ coach.name }}

+ {% for athlete in coach.athlete_set.all %} +

{{ athlete.name }}

+ {% endfor %} + {% resetcycle %} + {% endfor %} + +This example would return this HTML:: + +

José Mourinho

+

Thibaut Courtois

+

John Terry

+

Eden Hazard

+ +

Carlo Ancelotti

+

Manuel Neuer

+

Thomas Müller

+ +Notice how the first block ends with ``class="odd"`` and the new one starts +with ``class="odd"``. Without the ``{% resetcycle %}`` tag, the second block +would start with ``class="even"``. + +You can also reset named cycle tags:: + + {% for item in list %} +

+ {{ item.data }} +

+ {% ifchanged item.category %} +

{{ item.category }}

+ {% if not forloop.first %}{% resetcycle tick %}{% endif %} + {% endifchanged %} + {% endfor %} + +In this example, we have both the alternating odd/even rows and a "major" row +every fifth row. Only the five-row cycle is reset when a category changes. + .. templatetag:: spaceless ``spaceless`` diff --git a/docs/releases/1.11.txt b/docs/releases/1.11.txt index 3f232a56b8..20f2306ede 100644 --- a/docs/releases/1.11.txt +++ b/docs/releases/1.11.txt @@ -313,6 +313,9 @@ Templates so you can unpack the group object directly in a loop, e.g. ``{% for grouper, list in regrouped %}``. +* Added a :ttag:`resetcycle` template tag to allow resetting the sequence of + the :ttag:`cycle` template tag. + Tests ~~~~~ diff --git a/tests/template_tests/syntax_tests/test_resetcycle.py b/tests/template_tests/syntax_tests/test_resetcycle.py new file mode 100644 index 0000000000..669a849864 --- /dev/null +++ b/tests/template_tests/syntax_tests/test_resetcycle.py @@ -0,0 +1,95 @@ +from django.template import TemplateSyntaxError +from django.test import SimpleTestCase + +from ..utils import setup + + +class ResetCycleTagTests(SimpleTestCase): + + @setup({'resetcycle01': "{% resetcycle %}"}) + def test_resetcycle01(self): + with self.assertRaisesMessage(TemplateSyntaxError, "No cycles in template."): + self.engine.get_template('resetcycle01') + + @setup({'resetcycle02': "{% resetcycle undefinedcycle %}"}) + def test_resetcycle02(self): + with self.assertRaisesMessage(TemplateSyntaxError, "Named cycle 'undefinedcycle' does not exist."): + self.engine.get_template('resetcycle02') + + @setup({'resetcycle03': "{% cycle 'a' 'b' %}{% resetcycle undefinedcycle %}"}) + def test_resetcycle03(self): + with self.assertRaisesMessage(TemplateSyntaxError, "Named cycle 'undefinedcycle' does not exist."): + self.engine.get_template('resetcycle03') + + @setup({'resetcycle04': "{% cycle 'a' 'b' as ab %}{% resetcycle undefinedcycle %}"}) + def test_resetcycle04(self): + with self.assertRaisesMessage(TemplateSyntaxError, "Named cycle 'undefinedcycle' does not exist."): + self.engine.get_template('resetcycle04') + + @setup({'resetcycle05': "{% for i in test %}{% cycle 'a' 'b' %}{% resetcycle %}{% endfor %}"}) + def test_resetcycle05(self): + output = self.engine.render_to_string('resetcycle05', {'test': list(range(5))}) + self.assertEqual(output, 'aaaaa') + + @setup({'resetcycle06': "{% cycle 'a' 'b' 'c' as abc %}" + "{% for i in test %}" + "{% cycle abc %}" + "{% cycle '-' '+' %}" + "{% resetcycle %}" + "{% endfor %}"}) + def test_resetcycle06(self): + output = self.engine.render_to_string('resetcycle06', {'test': list(range(5))}) + self.assertEqual(output, 'ab-c-a-b-c-') + + @setup({'resetcycle07': "{% cycle 'a' 'b' 'c' as abc %}" + "{% for i in test %}" + "{% resetcycle abc %}" + "{% cycle abc %}" + "{% cycle '-' '+' %}" + "{% endfor %}"}) + def test_resetcycle07(self): + output = self.engine.render_to_string('resetcycle07', {'test': list(range(5))}) + self.assertEqual(output, 'aa-a+a-a+a-') + + @setup({'resetcycle08': "{% for i in outer %}" + "{% for j in inner %}" + "{% cycle 'a' 'b' %}" + "{% endfor %}" + "{% resetcycle %}" + "{% endfor %}"}) + def test_resetcycle08(self): + output = self.engine.render_to_string('resetcycle08', {'outer': list(range(2)), 'inner': list(range(3))}) + self.assertEqual(output, 'abaaba') + + @setup({'resetcycle09': "{% for i in outer %}" + "{% cycle 'a' 'b' %}" + "{% for j in inner %}" + "{% cycle 'X' 'Y' %}" + "{% endfor %}" + "{% resetcycle %}" + "{% endfor %}"}) + def test_resetcycle09(self): + output = self.engine.render_to_string('resetcycle09', {'outer': list(range(2)), 'inner': list(range(3))}) + self.assertEqual(output, 'aXYXbXYX') + + @setup({'resetcycle10': "{% for i in test %}" + "{% cycle 'X' 'Y' 'Z' as XYZ %}" + "{% cycle 'a' 'b' 'c' as abc %}" + "{% ifequal i 1 %}" + "{% resetcycle abc %}" + "{% endifequal %}" + "{% endfor %}"}) + def test_resetcycle10(self): + output = self.engine.render_to_string('resetcycle10', {'test': list(range(5))}) + self.assertEqual(output, 'XaYbZaXbYc') + + @setup({'resetcycle11': "{% for i in test %}" + "{% cycle 'X' 'Y' 'Z' as XYZ %}" + "{% cycle 'a' 'b' 'c' as abc %}" + "{% ifequal i 1 %}" + "{% resetcycle XYZ %}" + "{% endifequal %}" + "{% endfor %}"}) + def test_resetcycle11(self): + output = self.engine.render_to_string('resetcycle11', {'test': list(range(5))}) + self.assertEqual(output, 'XaYbXcYaZb')