Merge lp:~abentley/launchpad/restricted-diffs into lp:launchpad

Proposed by Aaron Bentley
Status: Merged
Merged at revision: not available
Proposed branch: lp:~abentley/launchpad/restricted-diffs
Merge into: lp:launchpad
Diff against target: 337 lines (+101/-21)
10 files modified
lib/canonical/launchpad/security.py (+24/-0)
lib/lp/code/browser/configure.zcml (+5/-0)
lib/lp/code/browser/diff.py (+10/-1)
lib/lp/code/browser/tests/test_tales.py (+16/-15)
lib/lp/code/configure.zcml (+3/-1)
lib/lp/code/interfaces/diff.py (+3/-0)
lib/lp/code/model/diff.py (+10/-2)
lib/lp/code/model/tests/test_diff.py (+13/-0)
lib/lp/code/stories/branches/xx-branchmergeproposals.txt (+15/-0)
lib/lp/code/templates/branchmergeproposal-diff.pt (+2/-2)
To merge this branch: bzr merge lp:~abentley/launchpad/restricted-diffs
Reviewer Review Type Date Requested Status
Michael Nelson (community) code Approve
Review via email: mp+19607@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Aaron Bentley (abentley) wrote :

= Summary =
Fix bug #328271, "Use the restricted librarian for diffs for private branches".

== Proposed fix ==
Use the restricted librarian for diffs of all branches, and let the standard
security mechanisms decide who can view the diffs.

== Pre-implementation notes ==
None

== Implementation details ==
PreviewDiffs are now accessible via the +files path segment, with the same mechanism as Launchpad components.

IPreviewDiff access was allowed to anyone. Now it is only allowed to those who
can access the merge proposal.

== Tests ==
bin/test -vt xx-branchmergeproposals.txt -t test_getFileByName

== Demo and Q/A ==
Create a private branch and propose it for merging. Wait until the diff is
generated. Go to the URL for the diff. Log out. Go to the URL for the diff.
You should get an access denied message.

= Launchpad lint =

Checking for conflicts. and issues in doctests and templates.
Running jslint, xmllint, pyflakes, and pylint.
Using normal rules.

Linting changed files:
  lib/lp/code/browser/configure.zcml
  lib/lp/code/configure.zcml
  lib/canonical/launchpad/security.py
  lib/lp/code/browser/diff.py
  lib/lp/code/model/diff.py
  lib/lp/code/stories/branches/xx-branchmergeproposals.txt
  lib/lp/code/model/tests/test_diff.py
  lib/lp/code/templates/branchmergeproposal-diff.pt
  lib/lp/code/interfaces/diff.py

== Pylint notices ==

lib/lp/code/model/diff.py
    18: [F0401] Unable to import 'lazr.delegates' (No module named delegates)
    210: [W0703, Diff.fromFile] Catch "Exception"

lib/lp/code/interfaces/diff.py
    20: [F0401] Unable to import 'lazr.restful.fields' (No module named restful)
    21: [F0401] Unable to import 'lazr.restful.declarations' (No module named restful)

Revision history for this message
Michael Nelson (michael.nelson) wrote :

Approve, just one missing blank line below, in addition to our IRC exchange.

> === modified file 'lib/lp/code/browser/diff.py'
> --- lib/lp/code/browser/diff.py 2010-02-02 17:32:23 +0000
> +++ lib/lp/code/browser/diff.py 2010-02-18 16:06:41 +0000
> @@ -10,10 +10,18 @@
>
>
> from canonical.launchpad import _
> +from canonical.launchpad.browser.librarian import FileNavigationMixin
> +from canonical.launchpad.webapp import Navigation
> +from canonical.launchpad.webapp.publisher import canonical_url
> from canonical.launchpad.webapp.tales import ObjectFormatterAPI
> +from lp.code.interfaces.diff import IPreviewDiff
> from lp.services.browser_helpers import get_plural_text
>
>
> +class PreviewDiffNavigation(Navigation, FileNavigationMixin):
> +
> + usedfor = IPreviewDiff
> +

Just need an extra line here.

> class PreviewDiffFormatterAPI(ObjectFormatterAPI):
> """Formatter for preview diffs."""
>

> === modified file 'lib/lp/code/stories/branches/xx-branchmergeproposals.txt'
> --- lib/lp/code/stories/branches/xx-branchmergeproposals.txt 2010-01-12 17:34:12 +0000
> +++ lib/lp/code/stories/branches/xx-branchmergeproposals.txt 2010-02-18 16:06:41 +0000
> @@ -614,10 +614,25 @@
> ... return find_tag_by_id(nopriv_browser.contents, 'review-diff')
>
> The text of the review diff is in the page.
> +
> >>> print repr(extract_text(get_review_diff()))
> u'Preview Diff\nDownload diff\n1\n...Fake Diff\u1010'
>
> +There is also a link to the diff URL, which is the preview diff URL plus
> +"+files/preview.diff"
> +
> + >>> link = get_review_diff().find('a')
> + >>> print link['href']
> + http://code.launchpad.dev/~person-name.../product-name.../branch.../+merge/.../+preview-diff/+files/preview.diff

17:20 < noodles775> abentley: your branch looks great. The only question I
have is whether there's really a need to include each segment of the url in
the doctest:
http://code.launchpad.dev/~person-name.../product-name.../branch.../+merge/.../+preview-diff/+files/preview.diff
17:21 < abentley> noodles775, I would be fine with just showing "/+preview-diff/+files/preview.diff". Would you prefer that?
17:22 < noodles775> abentley: yeah (not that I care much either way, it'd just mean it was within 78chars :))

Thanks Aaron.

review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'lib/canonical/launchpad/security.py'
--- lib/canonical/launchpad/security.py 2010-02-18 09:04:51 +0000
+++ lib/canonical/launchpad/security.py 2010-02-20 04:33:20 +0000
@@ -42,6 +42,7 @@
42 ICodeReviewComment, ICodeReviewCommentDeletion)42 ICodeReviewComment, ICodeReviewCommentDeletion)
43from lp.code.interfaces.codereviewvote import (43from lp.code.interfaces.codereviewvote import (
44 ICodeReviewVoteReference)44 ICodeReviewVoteReference)
45from lp.code.interfaces.diff import IPreviewDiff
45from lp.registry.interfaces.distribution import IDistribution46from lp.registry.interfaces.distribution import IDistribution
46from lp.registry.interfaces.distributionmirror import (47from lp.registry.interfaces.distributionmirror import (
47 IDistributionMirror)48 IDistributionMirror)
@@ -1708,6 +1709,29 @@
1708 AccessBranch(self.obj.target_branch).checkUnauthenticated())1709 AccessBranch(self.obj.target_branch).checkUnauthenticated())
17091710
17101711
1712class PreviewDiffView(AuthorizationBase):
1713 permission = 'launchpad.View'
1714 usedfor = IPreviewDiff
1715
1716 @property
1717 def bmp_view(self):
1718 return BranchMergeProposalView(self.obj.branch_merge_proposal)
1719
1720 def checkAuthenticated(self, user):
1721 """Is the user able to view the preview diff?
1722
1723 The user can see a preview diff if they can see the merge proposal.
1724 """
1725 return self.bmp_view.checkAuthenticated(user)
1726
1727 def checkUnauthenticated(self):
1728 """Is anyone able to view the branch merge proposal?
1729
1730 The user can see a preview diff if they can see the merge proposal.
1731 """
1732 return self.bmp_view.checkUnauthenticated()
1733
1734
1711class CodeReviewVoteReferenceEdit(AuthorizationBase):1735class CodeReviewVoteReferenceEdit(AuthorizationBase):
1712 permission = 'launchpad.Edit'1736 permission = 'launchpad.Edit'
1713 usedfor = ICodeReviewVoteReference1737 usedfor = ICodeReviewVoteReference
17141738
=== modified file 'lib/lp/code/browser/configure.zcml'
--- lib/lp/code/browser/configure.zcml 2010-02-17 11:19:42 +0000
+++ lib/lp/code/browser/configure.zcml 2010-02-20 04:33:20 +0000
@@ -946,6 +946,11 @@
946 attribute_to_parent="branch_merge_proposal"946 attribute_to_parent="branch_merge_proposal"
947 rootsite="code" />947 rootsite="code" />
948948
949 <browser:navigation
950 module="lp.code.browser.diff"
951 classes="
952 PreviewDiffNavigation"/>
953
949 <browser:page954 <browser:page
950 name="+diff"955 name="+diff"
951 for="lp.code.interfaces.diff.IPreviewDiff"956 for="lp.code.interfaces.diff.IPreviewDiff"
952957
=== modified file 'lib/lp/code/browser/diff.py'
--- lib/lp/code/browser/diff.py 2010-02-02 17:32:23 +0000
+++ lib/lp/code/browser/diff.py 2010-02-20 04:33:20 +0000
@@ -10,10 +10,19 @@
1010
1111
12from canonical.launchpad import _12from canonical.launchpad import _
13from canonical.launchpad.browser.librarian import FileNavigationMixin
14from canonical.launchpad.webapp import Navigation
15from canonical.launchpad.webapp.publisher import canonical_url
13from canonical.launchpad.webapp.tales import ObjectFormatterAPI16from canonical.launchpad.webapp.tales import ObjectFormatterAPI
17from lp.code.interfaces.diff import IPreviewDiff
14from lp.services.browser_helpers import get_plural_text18from lp.services.browser_helpers import get_plural_text
1519
1620
21class PreviewDiffNavigation(Navigation, FileNavigationMixin):
22
23 usedfor = IPreviewDiff
24
25
17class PreviewDiffFormatterAPI(ObjectFormatterAPI):26class PreviewDiffFormatterAPI(ObjectFormatterAPI):
18 """Formatter for preview diffs."""27 """Formatter for preview diffs."""
1928
@@ -24,7 +33,7 @@
24 if librarian_alias is None:33 if librarian_alias is None:
25 return None34 return None
26 else:35 else:
27 return librarian_alias.getURL()36 return canonical_url(self._context) + '/+files/preview.diff'
2837
29 def link(self, view_name):38 def link(self, view_name):
30 """The link to the diff should show the line count.39 """The link to the diff should show the line count.
3140
=== modified file 'lib/lp/code/browser/tests/test_tales.py'
--- lib/lp/code/browser/tests/test_tales.py 2009-11-18 01:53:19 +0000
+++ lib/lp/code/browser/tests/test_tales.py 2010-02-20 04:33:20 +0000
@@ -11,8 +11,9 @@
11from storm.store import Store11from storm.store import Store
12from zope.security.proxy import removeSecurityProxy12from zope.security.proxy import removeSecurityProxy
1313
14from canonical.launchpad.webapp.publisher import canonical_url
15from canonical.testing import LaunchpadFunctionalLayer
14from lp.testing import login, TestCaseWithFactory, test_tales16from lp.testing import login, TestCaseWithFactory, test_tales
15from canonical.testing import LaunchpadFunctionalLayer
1617
1718
18class TestPreviewDiffFormatter(TestCaseWithFactory):19class TestPreviewDiffFormatter(TestCaseWithFactory):
@@ -79,18 +80,18 @@
79 # If the lines added and removed are None, they are now shown.80 # If the lines added and removed are None, they are now shown.
80 preview = self._createPreviewDiff(10, added=None, removed=None)81 preview = self._createPreviewDiff(10, added=None, removed=None)
81 self.assertEqual(82 self.assertEqual(
82 '<a href="%s" class="diff-link">'83 '<a href="%s/+files/preview.diff" class="diff-link">'
83 '10 lines</a>'84 '10 lines</a>'
84 % preview.diff_text.getURL(),85 % canonical_url(preview),
85 test_tales('preview/fmt:link', preview=preview))86 test_tales('preview/fmt:link', preview=preview))
8687
87 def test_fmt_lines_some_added_no_removed(self):88 def test_fmt_lines_some_added_no_removed(self):
88 # If the added and removed values are not None, they are shown.89 # If the added and removed values are not None, they are shown.
89 preview = self._createPreviewDiff(10, added=4, removed=0)90 preview = self._createPreviewDiff(10, added=4, removed=0)
90 self.assertEqual(91 self.assertEqual(
91 '<a href="%s" class="diff-link">'92 '<a href="%s/+files/preview.diff" class="diff-link">'
92 '10 lines (+4/-0)</a>'93 '10 lines (+4/-0)</a>'
93 % preview.diff_text.getURL(),94 % canonical_url(preview),
94 test_tales('preview/fmt:link', preview=preview))95 test_tales('preview/fmt:link', preview=preview))
9596
96 def test_fmt_lines_files_modified(self):97 def test_fmt_lines_files_modified(self):
@@ -101,9 +102,9 @@
101 'file1': (1, 0),102 'file1': (1, 0),
102 'file2': (3, 0)})103 'file2': (3, 0)})
103 self.assertEqual(104 self.assertEqual(
104 '<a href="%s" class="diff-link">'105 '<a href="%s/+files/preview.diff" class="diff-link">'
105 '10 lines (+4/-0) 2 files modified</a>'106 '10 lines (+4/-0) 2 files modified</a>'
106 % preview.diff_text.getURL(),107 % canonical_url(preview),
107 test_tales('preview/fmt:link', preview=preview))108 test_tales('preview/fmt:link', preview=preview))
108109
109 def test_fmt_lines_one_file_modified(self):110 def test_fmt_lines_one_file_modified(self):
@@ -112,18 +113,18 @@
112 10, added=4, removed=0, diffstat={113 10, added=4, removed=0, diffstat={
113 'file': (3, 0)})114 'file': (3, 0)})
114 self.assertEqual(115 self.assertEqual(
115 '<a href="%s" class="diff-link">'116 '<a href="%s/+files/preview.diff" class="diff-link">'
116 '10 lines (+4/-0) 1 file modified</a>'117 '10 lines (+4/-0) 1 file modified</a>'
117 % preview.diff_text.getURL(),118 % canonical_url(preview),
118 test_tales('preview/fmt:link', preview=preview))119 test_tales('preview/fmt:link', preview=preview))
119120
120 def test_fmt_simple_conflicts(self):121 def test_fmt_simple_conflicts(self):
121 # Conflicts are indicated using text in the link.122 # Conflicts are indicated using text in the link.
122 preview = self._createPreviewDiff(10, 2, 3, u'conflicts')123 preview = self._createPreviewDiff(10, 2, 3, u'conflicts')
123 self.assertEqual(124 self.assertEqual(
124 '<a href="%s" class="diff-link">'125 '<a href="%s/+files/preview.diff" class="diff-link">'
125 '10 lines (+2/-3) (has conflicts)</a>'126 '10 lines (+2/-3) (has conflicts)</a>'
126 % preview.diff_text.getURL(),127 % canonical_url(preview),
127 test_tales('preview/fmt:link', preview=preview))128 test_tales('preview/fmt:link', preview=preview))
128129
129 def test_fmt_stale_empty_diff(self):130 def test_fmt_stale_empty_diff(self):
@@ -140,9 +141,9 @@
140 preview = self._createStalePreviewDiff(141 preview = self._createStalePreviewDiff(
141 500, 89, 340, diffstat=diffstat)142 500, 89, 340, diffstat=diffstat)
142 self.assertEqual(143 self.assertEqual(
143 '<a href="%s" class="diff-link">'144 '<a href="%s/+files/preview.diff" class="diff-link">'
144 '500 lines (+89/-340) 23 files modified</a>'145 '500 lines (+89/-340) 23 files modified</a>'
145 % preview.diff_text.getURL(),146 % canonical_url(preview),
146 test_tales('preview/fmt:link', preview=preview))147 test_tales('preview/fmt:link', preview=preview))
147148
148 def test_fmt_stale_non_empty_diff_with_conflicts(self):149 def test_fmt_stale_non_empty_diff_with_conflicts(self):
@@ -152,9 +153,9 @@
152 preview = self._createStalePreviewDiff(153 preview = self._createStalePreviewDiff(
153 500, 89, 340, u'conflicts', diffstat=diffstat)154 500, 89, 340, u'conflicts', diffstat=diffstat)
154 self.assertEqual(155 self.assertEqual(
155 '<a href="%s" class="diff-link">'156 '<a href="%s/+files/preview.diff" class="diff-link">'
156 '500 lines (+89/-340) 23 files modified (has conflicts)</a>'157 '500 lines (+89/-340) 23 files modified (has conflicts)</a>'
157 % preview.diff_text.getURL(),158 % canonical_url(preview),
158 test_tales('preview/fmt:link', preview=preview))159 test_tales('preview/fmt:link', preview=preview))
159160
160161
161162
=== modified file 'lib/lp/code/configure.zcml'
--- lib/lp/code/configure.zcml 2010-02-18 16:00:24 +0000
+++ lib/lp/code/configure.zcml 2010-02-20 04:33:20 +0000
@@ -929,7 +929,9 @@
929 <allow interface="lp.code.interfaces.diff.IStaticDiff" />929 <allow interface="lp.code.interfaces.diff.IStaticDiff" />
930 </class>930 </class>
931 <class class="lp.code.model.diff.PreviewDiff">931 <class class="lp.code.model.diff.PreviewDiff">
932 <allow interface="lp.code.interfaces.diff.IPreviewDiff" />932 <require
933 permission="launchpad.View"
934 interface="lp.code.interfaces.diff.IPreviewDiff"/>
933 </class>935 </class>
934 <securedutility936 <securedutility
935 component="lp.code.model.diff.StaticDiff"937 component="lp.code.model.diff.StaticDiff"
936938
=== modified file 'lib/lp/code/interfaces/diff.py'
--- lib/lp/code/interfaces/diff.py 2010-02-02 17:19:05 +0000
+++ lib/lp/code/interfaces/diff.py 2010-02-20 04:33:20 +0000
@@ -136,3 +136,6 @@
136 'If the preview diff is stale, it is out of date when '136 'If the preview diff is stale, it is out of date when '
137 'compared to the tip revisions of the source, target, and '137 'compared to the tip revisions of the source, target, and '
138 'possibly prerequisite branches.')))138 'possibly prerequisite branches.')))
139
140 def getFileByName(filename):
141 """Return the file under +files with specified name."""
139142
=== modified file 'lib/lp/code/model/diff.py'
--- lib/lp/code/model/diff.py 2010-02-02 17:19:05 +0000
+++ lib/lp/code/model/diff.py 2010-02-20 04:33:20 +0000
@@ -27,9 +27,10 @@
27from canonical.database.sqlbase import SQLBase27from canonical.database.sqlbase import SQLBase
28from canonical.uuid import generate_uuid28from canonical.uuid import generate_uuid
2929
30from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet
31from canonical.launchpad.interfaces.launchpad import NotFoundError
30from lp.code.interfaces.diff import (32from lp.code.interfaces.diff import (
31 IDiff, IPreviewDiff, IStaticDiff, IStaticDiffSource)33 IDiff, IPreviewDiff, IStaticDiff, IStaticDiffSource)
32from canonical.launchpad.interfaces.librarian import ILibraryFileAliasSet
3334
3435
35class Diff(SQLBase):36class Diff(SQLBase):
@@ -195,7 +196,7 @@
195 if filename is None:196 if filename is None:
196 filename = generate_uuid() + '.txt'197 filename = generate_uuid() + '.txt'
197 diff_text = getUtility(ILibraryFileAliasSet).create(198 diff_text = getUtility(ILibraryFileAliasSet).create(
198 filename, size, diff_content, 'text/x-diff')199 filename, size, diff_content, 'text/x-diff', restricted=True)
199 diff_content.seek(0)200 diff_content.seek(0)
200 diff_content_bytes = diff_content.read(size)201 diff_content_bytes = diff_content.read(size)
201 diff_lines_count = len(diff_content_bytes.strip().split('\n'))202 diff_lines_count = len(diff_content_bytes.strip().split('\n'))
@@ -383,3 +384,10 @@
383 return True384 return True
384 else:385 else:
385 return False386 return False
387
388 def getFileByName(self, filename):
389 """See `IPreviewDiff`."""
390 if filename == 'preview.diff' and self.diff_text is not None:
391 return self.diff_text
392 else:
393 raise NotFoundError(filename)
386394
=== modified file 'lib/lp/code/model/tests/test_diff.py'
--- lib/lp/code/model/tests/test_diff.py 2010-02-02 17:19:05 +0000
+++ lib/lp/code/model/tests/test_diff.py 2010-02-20 04:33:20 +0000
@@ -15,6 +15,7 @@
15from bzrlib import trace15from bzrlib import trace
16import transaction16import transaction
1717
18from canonical.launchpad.interfaces.launchpad import NotFoundError
18from canonical.launchpad.webapp import canonical_url, errorlog19from canonical.launchpad.webapp import canonical_url, errorlog
19from canonical.launchpad.webapp.testing import verifyObject20from canonical.launchpad.webapp.testing import verifyObject
20from canonical.testing import LaunchpadFunctionalLayer, LaunchpadZopelessLayer21from canonical.testing import LaunchpadFunctionalLayer, LaunchpadZopelessLayer
@@ -123,6 +124,7 @@
123 content = ''.join(unified_diff('', "1234567890" * 10))124 content = ''.join(unified_diff('', "1234567890" * 10))
124 diff = self._create_diff(content)125 diff = self._create_diff(content)
125 self.assertEqual(content, diff.text)126 self.assertEqual(content, diff.text)
127 self.assertTrue(diff.diff_text.restricted)
126128
127 def test_oversized_normal(self):129 def test_oversized_normal(self):
128 # A diff smaller than config.diff.max_read_size is not oversized.130 # A diff smaller than config.diff.max_read_size is not oversized.
@@ -478,6 +480,17 @@
478 finally:480 finally:
479 logger.removeHandler(handler)481 logger.removeHandler(handler)
480482
483 def test_getFileByName(self):
484 diff = self._createProposalWithPreviewDiff().preview_diff
485 self.assertEqual(diff.diff_text, diff.getFileByName('preview.diff'))
486 self.assertRaises(
487 NotFoundError, diff.getFileByName, 'different.name')
488
489 def test_getFileByName_with_no_diff(self):
490 diff = self._createProposalWithPreviewDiff(content='').preview_diff
491 self.assertRaises(
492 NotFoundError, diff.getFileByName, 'preview.diff')
493
481494
482def test_suite():495def test_suite():
483 return TestLoader().loadTestsFromName(__name__)496 return TestLoader().loadTestsFromName(__name__)
484497
=== modified file 'lib/lp/code/stories/branches/xx-branchmergeproposals.txt'
--- lib/lp/code/stories/branches/xx-branchmergeproposals.txt 2010-01-12 17:34:12 +0000
+++ lib/lp/code/stories/branches/xx-branchmergeproposals.txt 2010-02-20 04:33:20 +0000
@@ -614,10 +614,25 @@
614 ... return find_tag_by_id(nopriv_browser.contents, 'review-diff')614 ... return find_tag_by_id(nopriv_browser.contents, 'review-diff')
615615
616The text of the review diff is in the page.616The text of the review diff is in the page.
617
617 >>> print repr(extract_text(get_review_diff()))618 >>> print repr(extract_text(get_review_diff()))
618 u'Preview Diff\nDownload diff\n1\n...Fake Diff\u1010'619 u'Preview Diff\nDownload diff\n1\n...Fake Diff\u1010'
619620
621There is also a link to the diff URL, which is the preview diff URL plus
622"+files/preview.diff"
623
624 >>> link = get_review_diff().find('a')
625 >>> print link['href']
626 http.../+preview-diff/+files/preview.diff
627 >>> nopriv_browser.open(str(link['href']))
628 >>> print nopriv_browser.contents
629 ---
630 +++
631 @@ -1,0 +1,1 @@
632 +Fake Diff...
633
620If no diff is present, nothing is shown.634If no diff is present, nothing is shown.
635
621 >>> login('admin@canonical.com')636 >>> login('admin@canonical.com')
622 >>> removeSecurityProxy(bmp).preview_diff = None637 >>> removeSecurityProxy(bmp).preview_diff = None
623 >>> logout()638 >>> logout()
624639
=== modified file 'lib/lp/code/templates/branchmergeproposal-diff.pt'
--- lib/lp/code/templates/branchmergeproposal-diff.pt 2009-11-14 09:36:50 +0000
+++ lib/lp/code/templates/branchmergeproposal-diff.pt 2010-02-20 04:33:20 +0000
@@ -8,7 +8,7 @@
8 <div class="boardCommentDetails filename">8 <div class="boardCommentDetails filename">
9 <ul class="horizontal">9 <ul class="horizontal">
10 <li>10 <li>
11 <a tal:attributes="href attachment/getURL">11 <a tal:attributes="href view/preview_diff/fmt:url">
12 <img src="/@@/download"/>12 <img src="/@@/download"/>
13 Download diff13 Download diff
14 </a>14 </a>
@@ -30,4 +30,4 @@
30 </tal:empty-diff>30 </tal:empty-diff>
31 </div>31 </div>
3232
33</tal:root>
34\ No newline at end of file33\ No newline at end of file
34</tal:root>