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')