Merge lp:~jtv/launchpad/templates-listing into lp:launchpad

Proposed by Jeroen T. Vermeulen on 2010-09-11
Status: Merged
Approved by: Aaron Bentley on 2010-09-13
Approved revision: no longer in the source branch.
Merged at revision: 11542
Proposed branch: lp:~jtv/launchpad/templates-listing
Merge into: lp:launchpad
Diff against target: 716 lines (+500/-86)
7 files modified
lib/lp/translations/browser/distroseries.py (+9/-2)
lib/lp/translations/browser/potemplate.py (+142/-0)
lib/lp/translations/browser/productseries.py (+8/-2)
lib/lp/translations/browser/tests/test_seriestemplatesview.py (+332/-0)
lib/lp/translations/stories/standalone/xx-potemplate-edit.txt (+1/-3)
lib/lp/translations/stories/standalone/xx-series-templates.txt (+4/-8)
lib/lp/translations/templates/object-templates.pt (+4/-71)
To merge this branch: bzr merge lp:~jtv/launchpad/templates-listing
Reviewer Review Type Date Requested Status
Aaron Bentley (community) Approve on 2010-09-13
Launchpad code reviewers code 2010-09-11 Pending
Review via email: mp+35185@code.launchpad.net

Commit Message

Move DistroSeries:+templates out of TAL.

Description of the Change

= Bug 608349 =

Even after the phenomenal optimization job Adi and Danilo did on the DistroSeries:+templates lists, Zope and TAL are still taking too much time to make this page render comfortably. In the bug ticket they identify further optimization opportunities: avoiding the URL formatter and replacing the templating engine with a faster one.

In this hobby branch I move the rendering of the template listing rows from the TAL into the browser code. This keeps the TAL cleaner and simpler, takes the TAL parser out of the critical loop, and allows for more conventional optimization. I hope the new browser code in its own way exposes the structure more clearly than the TAL with its conditionals and irregularly-sized columns did.

On my local machine, this speeds up an Ubuntu-sized +templates page by 2—3×.

I did keep the central loop in TAL. Growing one huge string for potentially thousands of templates could get quite unwieldy; the templating engine will hopefully do things in a more "streaming" fashion. With any luck the listing's HTML can start flowing to the client before rendering has completed.

Two "conventional optimizations" are noteworthy:
 * The view classes can (and do) provide their own, faster alternatives to canonical_url.
 * Using self.user instead of context/required:launchpad.AnyPerson avoids the Zope security infrastructure.

By far most of the new code is actually in a new unit-test for the views. The browser-code approach allows for much more extensive and detailed testing. In particular of course the test verifies that the optimized URL generation code is equivalent to canonical_url. It actually produces shorter, relative URLs; this is the only semantic change and it affected two of our existing test stories in a very minor way. The rest of the HTML also got about 40% shorter (or 50% in number of lines), and I think easier to read.

Jeroen

To post a comment you must log in.
Robert Collins (lifeless) wrote :

This isn't a review, but I'm curious how you've established that this
is faster (so that I can have a look in the same way at
ubuntu/+assignments. Also, we have a new template engine in the
pipeline :- we probably don't want a lot of procedural code doing
template-work (or we want all our code doing that).

Uhm for generating big texts in python, do this:
buffer = []
for <blah>:
   buffer.append(somestring)
...
return ''.join(buffer)

This isn't as efficient as a pure chunking end-to-end implementation,
but it is the next best thing.

Lastly, you might like to nab Gary or someone from foundations to talk
about the implications of canonical_url returning relative paths :
we've got some stuff changing in that area just recently.

-Rob

Jeroen T. Vermeulen (jtv) wrote :

> This isn't a review, but I'm curious how you've established that this
> is faster

I haven't actually established that it is faster; I would like to do that based on timings on actual production data on one of the test servers. In practice it's not so much the speed of producing these listings that we care about, as it is the speed of producing specifically the Ubuntu listings.

> Also, we have a new template engine in the
> pipeline :- we probably don't want a lot of procedural code doing
> template-work (or we want all our code doing that).

This is a bit of a special case given the enormous amounts of data that show up in the page. (There are good reasons for doing it this way, so "just batch it" is a poor plan B).

If the new template engine turns out to be faster than straight-through python code, then we should probably scuttle this branch.

> Uhm for generating big texts in python, do this:
> buffer = []
> for <blah>:
> buffer.append(somestring)
> ...
> return ''.join(buffer)
>
> This isn't as efficient as a pure chunking end-to-end implementation,
> but it is the next best thing.

I do that for smaller chunks. For the page as a whole however I think the templating engine is likely to be better tuned. With literally thousands of lines on a page, the ability to start sending HTML before the full list has been rendered could be a real plus.

> Lastly, you might like to nab Gary or someone from foundations to talk
> about the implications of canonical_url returning relative paths :
> we've got some stuff changing in that area just recently.

Something I didn't make clear in the cover letter: it's only a very specific use-case of canonical_url, on one kind of page and for one kind of object that it replaces. The tests should expose any changes that break the equivalence to canonical_url.

Jeroen

Robert Collins (lifeless) wrote :

On Sat, Sep 11, 2010 at 10:37 PM, Jeroen T. Vermeulen <email address hidden> wrote:
>> This isn't a review, but I'm curious how you've established that this
>> is faster
>
> I haven't actually established that it is faster; I would like to do that based on timings on actual production data on one of the test servers.  In practice it's not so much the speed of producing these listings that we care about, as it is the speed of producing specifically the Ubuntu listings.

Staging is currently not suitable for perf testing except under quite
controlled circumstances (see my bug on foundations - 'staging gets
overloaded') - just a heads up. If I was doing this I think I'd
compare the page before and after locally using the comment field to
get local timings.

>> Also, we have a new template engine in the
>> pipeline :- we probably don't want a lot of procedural code doing
>> template-work (or we want all our code doing that).
>
> This is a bit of a special case given the enormous amounts of data that show up in the page.  (There are good reasons for doing it this way, so "just batch it" is a poor plan B).
>
> If the new template engine turns out to be faster than straight-through python code, then we should probably scuttle this branch.

I don't really like batching most of the time either. As for template
being faster - I wouldn't necessarily expect that, but the tradeoff
between understandability and performance might be less skewed with
chameleon. You could try it - its in the tree now.

>> This isn't as efficient as a pure chunking end-to-end implementation,
>> but it is the next best thing.
>
> I do that for smaller chunks.  For the page as a whole however I think the templating engine is likely to be better tuned.  With literally thousands of lines on a page, the ability to start sending HTML before the full list has been rendered could be a real plus.

With our current design we simply cannot do that:
 - any exception before publication finishes generates an OOPS
 - so no data is sent until we know we won't have an OOPS

-> Don't worry about incremental performance here, worry about total
performance.

-Rob

Jeroen T. Vermeulen (jtv) wrote :

I don't know what you mean with the part about the comment field. Perhaps you're thinking of my Librarian branch that deals with discrepancies between the choices of default or slave stores?

Anyway, the reason I'm not testing locally is memory. If I create the thousands of packages and templates I need for a realistic test, the differences in caching characteristics between my local system and a production system would probably invalidate the results.

Robert Collins (lifeless) wrote :

On Sun, Sep 12, 2010 at 4:45 PM, Jeroen T. Vermeulen <email address hidden> wrote:
> I don't know what you mean with the part about the comment field.  Perhaps you're thinking of my Librarian branch that deals with discrepancies between the choices of default or slave stores?

No. View the source of a page - at the bottom there is a comment
listing some stats, including the time to render.

> Anyway, the reason I'm not testing locally is memory.  If I create the thousands of packages and templates I need for a realistic test, the differences in caching characteristics between my local system and a production system would probably invalidate the results.

The theory is that everything is fast except for rendering: adding
thousands of templates on on package should be fine : even at 1K per
row, that would be MB's, not 100's of MB's of increased:
 - pgsql buffer
 - storm buffer
 - objects in memory.

As long as you don't drive yourself into swap you'll be able to tell,
with a much lower turnaround time than reproducing on the actual
dataset.

If you aren't in swap, and this branch is a faster - say 15%
consistently faster - then its a pretty good sign.

-Rob

Jeroen T. Vermeulen (jtv) wrote :

Okay, I tried it locally. I hadn't thought of the HTML comment as a "field" before, hence my confusion.

At any rate, using "wget" to eliminate as many extraneous factors as I could, the code I present here was more than twice as fast as the code we have in devel for about 1,400 templates. When I increased the number of templates by another 1,000, my version came closer to 3× as fast as devel. I think that's worthwhile.

Jeroen T. Vermeulen (jtv) wrote :

I just realized I should have posted this as a reply, not a separate comment. In a nutshell: two to three times faster, gaining more with larger data sets; and 40% smaller output (though I didn't particularly aim for it, just something nicer to read) with less than half the number of lines.

I do agree that in principle we should use the TAL. In this case however, with the variable columns list, it was a small step from (pre) moving into the view all cyclomatic complexity that shouldn't have been in the TAL in the first place, to (post) rendering entire rows there.

Aaron Bentley (abentley) wrote :

You haven't tested with chameleon, but you've said that even if switching to chameleon gives us tech debt, this approach gets rid of some other tech debt, and I'm happy with that.

It does suggest that we should put some work into speeding up canonical_url-- perhaps we really want "canonical_urls".

review: Approve
Jeroen T. Vermeulen (jtv) wrote :

Thanks. I wouldn't be surprised if after this, we could get yet another speedup from having some cleverly optimized bulk version of canonical_url.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/translations/browser/distroseries.py'
2--- lib/lp/translations/browser/distroseries.py 2010-08-24 10:45:57 +0000
3+++ lib/lp/translations/browser/distroseries.py 2010-09-13 16:27:49 +0000
4@@ -178,14 +178,16 @@
5 self.request.response.addInfoNotification(
6 'Your changes have been applied.')
7 self.next_url = canonical_url(
8- self.context, rootsite='translations', view_name='+language-packs')
9+ self.context, rootsite='translations',
10+ view_name='+language-packs')
11
12 @action("Request", condition=is_langpack_admin)
13 def request_action(self, action, data):
14 self.updateContextFromData(data)
15 self._request_full_export()
16 self.next_url = canonical_url(
17- self.context, rootsite='translations', view_name='+language-packs')
18+ self.context, rootsite='translations',
19+ view_name='+language-packs')
20
21
22 class DistroSeriesTemplatesView(BaseSeriesTemplatesView):
23@@ -195,6 +197,11 @@
24 super(DistroSeriesTemplatesView, self).initialize(
25 series=self.context, is_distroseries=True)
26
27+ def constructTemplateURL(self, template):
28+ """See `BaseSeriesTemplatesView`."""
29+ return '+source/%s/+pots/%s' % (
30+ template.sourcepackagename.name, template.name)
31+
32
33 class DistroSeriesView(LaunchpadView, TranslationsMixin):
34
35
36=== modified file 'lib/lp/translations/browser/potemplate.py'
37--- lib/lp/translations/browser/potemplate.py 2010-09-03 13:29:58 +0000
38+++ lib/lp/translations/browser/potemplate.py 2010-09-13 16:27:49 +0000
39@@ -60,6 +60,7 @@
40 )
41 from canonical.launchpad.webapp.launchpadform import ReturnToReferrerMixin
42 from canonical.launchpad.webapp.menu import structured
43+from canonical.launchpad.webapp.tales import DateTimeFormatterAPI
44 from canonical.lazr.utils import smartquote
45 from lp.app.enums import service_uses_launchpad
46 from lp.app.errors import NotFoundError
47@@ -838,6 +839,8 @@
48 self.can_edit = (
49 self.can_admin or check_permission('launchpad.Edit', series))
50
51+ self.user_is_logged_in = (self.user is not None)
52+
53 def iter_templates(self):
54 potemplateset = getUtility(IPOTemplateSet)
55 return potemplateset.getSubset(
56@@ -850,3 +853,142 @@
57 return "active-template"
58 else:
59 return "inactive-template"
60+
61+ def _renderSourcePackage(self, template):
62+ """Render the `SourcePackageName` for `template`."""
63+ if self.is_distroseries:
64+ return cgi.escape(template.sourcepackagename.name)
65+ else:
66+ return None
67+
68+ def _renderTemplateLink(self, template, url):
69+ """Render a link to `template`.
70+
71+ :param template: The target `POTemplate`.
72+ :param url: The cached URL for `template`.
73+ :return: HTML for a link to `template`.
74+ """
75+ text = '<a href="%s">%s</a>' % (url, cgi.escape(template.name))
76+ if not template.iscurrent:
77+ text += ' (inactive)'
78+ return text
79+
80+ def _renderLastUpdateDate(self, template):
81+ """Render a template's "last updated" column."""
82+ formatter = DateTimeFormatterAPI(template.date_last_updated)
83+ full_time = formatter.datetime()
84+ date = formatter.approximatedate()
85+ return ''.join([
86+ '<span class="sortkey">%s</span>' % full_time,
87+ '<span class="lastupdate_column" title="%s">%s</span>' % (
88+ full_time, date),
89+ ])
90+
91+ def _renderAction(self, base_url, name, path, sprite, enabled):
92+ """Render an action for the "actions" column.
93+
94+ :param base_url: The cached URL for `template`.
95+ :param name: Action name for display in the UI.
96+ :param path: Path suffix for the action (relative to `base_url`).
97+ :param sprite: Sprite class for the action.
98+ :param enabled: Show this action? If not, return empty string.
99+ :return: HTML for the contents of the "actions" column.
100+ """
101+ if not enabled:
102+ return ''
103+
104+ parameters = {
105+ 'base_url': base_url,
106+ 'name': name,
107+ 'path': path,
108+ 'sprite': sprite,
109+ }
110+ return (
111+ '<a class="sprite %(sprite)s" href="%(base_url)s/%(path)s">'
112+ '%(name)s'
113+ '</a>') % parameters
114+
115+ def _renderActionsColumn(self, template, base_url):
116+ """Render a template's "actions" column."""
117+ if not self.user_is_logged_in:
118+ return None
119+
120+ actions = [
121+ ('Edit', '+edit', 'edit', self.can_edit),
122+ ('Upload', '+upload', 'add', self.can_edit),
123+ ('Download', '+export', 'download', self.user_is_logged_in),
124+ ('Administer', '+admin', 'edit', self.can_admin),
125+ ]
126+ links = [
127+ self._renderAction(base_url, *action) for action in actions]
128+ html = '<div class="template_links">\n%s</div>'
129+ return html % '\n'.join(links)
130+
131+ def _renderField(self, column_class, content, tag='td'):
132+ """Create a table field of the given class and contents.
133+
134+ :param column_class: CSS class for this column.
135+ :param content: HTML to go into the column. If None, the field
136+ will be omitted entirely. (To produce an empty column, pass
137+ the empty string instead.)
138+ :param tag: The HTML tag to surround the field in.
139+ :return: HTML for the entire table field, or the empty string if
140+ `content` was None.
141+ """
142+ if content is None:
143+ return ''
144+ else:
145+ return '<%s class="%s">%s</%s>' % (
146+ tag, column_class, content, tag)
147+
148+ def constructTemplateURL(self, template):
149+ """Build the URL for `template`.
150+
151+ Since this is performance-critical, views are allowed to
152+ override it with optimized implementations.
153+ """
154+ return canonical_url(template)
155+
156+ def renderTemplatesHeader(self):
157+ """Render HTML for the templates table header."""
158+ if self.is_distroseries:
159+ sourcepackage_header = "Source package"
160+ else:
161+ sourcepackage_header = None
162+ if self.user_is_logged_in:
163+ actions_header = "Actions"
164+ else:
165+ actions_header = None
166+ columns = [
167+ ('priority_column', "Priority"),
168+ ('sourcepackage_column', sourcepackage_header),
169+ ('template_column', "Template name"),
170+ ('length_column', "Length"),
171+ ('lastupdate_column', "Updated"),
172+ ('actions_column', actions_header),
173+ ]
174+ return '\n'.join([
175+ self._renderField(*column, tag='th') for column in columns])
176+
177+ def renderTemplateRow(self, template):
178+ """Render HTML for an entire template row."""
179+ if not self.can_edit and not template.iscurrent:
180+ return ""
181+
182+ # Cached URL for template.
183+ base_url = self.constructTemplateURL(template)
184+
185+ fields = [
186+ ('priority_column', template.priority),
187+ ('sourcepackage_column', self._renderSourcePackage(template)),
188+ ('template_column', self._renderTemplateLink(template, base_url)),
189+ ('length_column', template.messagecount),
190+ ('lastupdate_column', self._renderLastUpdateDate(template)),
191+ ('actions_column', self._renderActionsColumn(template, base_url)),
192+ ]
193+
194+ tds = [self._renderField(*field) for field in fields]
195+
196+ css = self.rowCSSClass(template)
197+ return '<tr class="template_row %s">\n%s</tr>\n' % (
198+ css, '\n'.join(tds))
199
200=== modified file 'lib/lp/translations/browser/productseries.py'
201--- lib/lp/translations/browser/productseries.py 2010-08-24 10:45:57 +0000
202+++ lib/lp/translations/browser/productseries.py 2010-09-13 16:27:49 +0000
203@@ -250,7 +250,8 @@
204 'administrator in the coming few days. You can track '
205 'your upload\'s status in the '
206 '<a href="%s/+imports">Translation Import Queue</a>' %(
207- canonical_url(self.context, rootsite='translations'))))
208+ canonical_url(self.context,
209+ rootsite='translations'))))
210
211 elif is_tar_filename(filename):
212 # Add the whole tarball to the import queue.
213@@ -277,7 +278,8 @@
214 'your upload\'s status in the '
215 '<a href="%s/+imports">Translation Import Queue</a>' %(
216 num, plural_s, plural_s, itthey,
217- canonical_url(self.context, rootsite='translations'))))
218+ canonical_url(self.context,
219+ rootsite='translations'))))
220 if len(conflicts) > 0:
221 if len(conflicts) == 1:
222 warning = (
223@@ -492,6 +494,10 @@
224 super(ProductSeriesTemplatesView, self).initialize(
225 series=self.context, is_distroseries=False)
226
227+ def constructTemplateURL(self, template):
228+ """See `BaseSeriesTemplatesView`."""
229+ return '+pots/%s' % template.name
230+
231
232 class LinkTranslationsBranchView(LaunchpadEditFormView):
233 """View to set the series' translations export branch."""
234
235=== added file 'lib/lp/translations/browser/tests/test_seriestemplatesview.py'
236--- lib/lp/translations/browser/tests/test_seriestemplatesview.py 1970-01-01 00:00:00 +0000
237+++ lib/lp/translations/browser/tests/test_seriestemplatesview.py 2010-09-13 16:27:49 +0000
238@@ -0,0 +1,332 @@
239+# Copyright 2010 Canonical Ltd. This software is licensed under the
240+# GNU Affero General Public License version 3 (see the file LICENSE).
241+
242+"""Tests for `BaseSeriesTemplatesView` and descendants."""
243+
244+__metaclass__ = type
245+
246+import re
247+
248+from zope.security.proxy import removeSecurityProxy
249+
250+from canonical.launchpad.ftests import login
251+from canonical.launchpad.webapp.publisher import canonical_url
252+from canonical.launchpad.webapp.servers import LaunchpadTestRequest
253+from canonical.testing import DatabaseFunctionalLayer
254+from lp.registry.model.distroseries import DistroSeries
255+from lp.registry.model.productseries import ProductSeries
256+from lp.translations.browser.distroseries import DistroSeriesTemplatesView
257+from lp.translations.browser.productseries import ProductSeriesTemplatesView
258+from lp.testing import (
259+ login_person,
260+ TestCaseWithFactory,
261+ )
262+from lp.testing.sampledata import ADMIN_EMAIL
263+
264+
265+class SeriesTemplatesViewScenario:
266+
267+ # The view class to test.
268+ view_class = None
269+
270+ # The expected list of columns for the view_class, each shown as a
271+ # list holding the column's CSS class.
272+ columns = None
273+
274+ def makeTemplateContext(self):
275+ """Create makePOTemplate arguments to create a series."""
276+ raise NotImplementedError()
277+
278+ def _makeTemplate(self, **kwargs):
279+ """Create a distro- or productseries for the view."""
280+ args_dict = self.makeTemplateContext()
281+ args_dict.update(kwargs)
282+ return self.factory.makePOTemplate(**args_dict)
283+
284+ def _getSeries(self, template):
285+ """Get `template`'s distro- or productseries."""
286+ return template.distroseries or template.productseries
287+
288+ def _makeView(self, template=None):
289+ """Create a `BaseTemplatesView` containing `template`."""
290+ if template is None:
291+ template = self._makeTemplate()
292+ request = LaunchpadTestRequest()
293+ view = self.view_class(self._getSeries(template), request)
294+ view.initialize()
295+ return view
296+
297+ def _findTagClasses(self, html, tag):
298+ """Find the CSS classes for all instances of `tag`s in `html`.
299+
300+ Returns a list of lists. The outer list represents instances of
301+ `tag`, in the order in which they are found. The inner lists
302+ hold the respective sets of HTML classes for these tags, sorted
303+ alphabetically.
304+ """
305+ regex = '<%s [^>]*class="([^"]*)"' % tag
306+ return [
307+ sorted(css_class.split())
308+ for css_class in re.findall(regex, html)]
309+
310+ def _findActions(self, html):
311+ """Find the available actions in an HTML actions column."""
312+ return re.findall('<[^>]*>([^<]*)</[^>]*', html)
313+
314+ def test_has_the_right_columns(self):
315+ # Test the column headers against the expected list.
316+ view = self._makeView()
317+ header = view.renderTemplatesHeader()
318+ self.assertEqual(self.columns, self._findTagClasses(header, 'th'))
319+
320+ def test_logging_in_adds_actions_column(self):
321+ # A logged-in user gets to see an extra "actions" column.
322+ template = self._makeTemplate()
323+ login_person(self.factory.makePerson())
324+ view = self._makeView(template)
325+ columns = self.columns + [['actions_column']]
326+ header = view.renderTemplatesHeader()
327+ self.assertEqual(columns, self._findTagClasses(header, 'th'))
328+ row = view.renderTemplateRow(template)
329+ self.assertEqual(columns, self._findTagClasses(row, 'td'))
330+
331+ def test_user_actions(self):
332+ # The only action offered to regular users is Download.
333+ template = self._makeTemplate()
334+ url = canonical_url(template)
335+ login_person(self.factory.makePerson())
336+ view = self._makeView(template)
337+
338+ self.assertEqual(
339+ ['Download'],
340+ self._findActions(view._renderActionsColumn(template, url)))
341+
342+ def test_admin_actions(self):
343+ # An administrator gets to see all actions on a template.
344+ template = self._makeTemplate()
345+ url = canonical_url(template)
346+ login(ADMIN_EMAIL)
347+ view = self._makeView(template)
348+
349+ self.assertEqual(
350+ ['Edit', 'Upload', 'Download', 'Administer'],
351+ self._findActions(view._renderActionsColumn(template, url)))
352+
353+ def test_edit_actions(self):
354+ # A non-admin user with edit rights gets the Edit, Upload, and
355+ # Download actions.
356+ template = self._makeTemplate()
357+ url = canonical_url(template)
358+ login_person(self.factory.makePerson())
359+ view = self._makeView(template)
360+ view.can_edit = True
361+
362+ self.assertEqual(
363+ ['Edit', 'Upload', 'Download'],
364+ self._findActions(view._renderActionsColumn(template, url)))
365+
366+ def test_constructs_correct_urls(self):
367+ # The view classes can override constructTemplateURL with
368+ # optimized versions. These can produce either an absolute URL
369+ # that exactly matches the template's canonical_url, or a
370+ # relative one starting from the series' canonical_url.
371+ template = self._makeTemplate()
372+ view = self._makeView(template)
373+
374+ series_url = canonical_url(
375+ self._getSeries(template), rootsite='translations')
376+ constructed_url = view.constructTemplateURL(template)
377+
378+ self.assertIn(
379+ canonical_url(template),
380+ (constructed_url, '/'.join([series_url, constructed_url])))
381+
382+ def test_renderTemplateLink(self):
383+ # _renderTemplateLink renders a link to the template.
384+ template = self._makeTemplate()
385+ view = self._makeView(template)
386+
387+ url = view.constructTemplateURL(template)
388+ link = view._renderTemplateLink(template, url)
389+
390+ self.assertIn('<a ', link)
391+ self.assertIn('href="%s"' % url, link)
392+ self.assertIn('>%s<' % template.name, link)
393+
394+ def test_renderTemplateLink_marks_disabled(self):
395+ # _renderTemplateLinks marks disabled templates as "(inactive)."
396+ template = self._makeTemplate()
397+ view = self._makeView(template)
398+ url = canonical_url(template)
399+
400+ removeSecurityProxy(template).iscurrent = True
401+ self.assertNotIn(
402+ '(inactive)', view._renderTemplateLink(template, url))
403+ removeSecurityProxy(template).iscurrent = False
404+ self.assertIn('(inactive)', view._renderTemplateLink(template, url))
405+
406+ def test_renderLastUpdateDate_sets_sortkey(self):
407+ # _renderLastUpdateDate sets the full date as the column's sort
408+ # key, so that clicking on the column header sorts by date (even
409+ # if sorting alphabetically by the visible date might produce a
410+ # different ordering).
411+ template = self._makeTemplate()
412+ view = self._makeView(template)
413+
414+ date_field = view._renderLastUpdateDate(template)
415+
416+ # The sort key is set in a span of class "sortkey."
417+ sortkey_match = re.findall(
418+ '<span class="sortkey">([^<]*)</span>', date_field)
419+ self.assertIsNot(None, sortkey_match)
420+ self.assertEqual(1, len(sortkey_match))
421+
422+ # The column also has the same full date as a tooltip.
423+ full_date = sortkey_match[0].strip()
424+ self.assertIn('title="%s"' % full_date, date_field)
425+
426+ def test_renderAction_returns_empty_string_if_not_enabled(self):
427+ view = self._makeView()
428+ self.assertEqual(
429+ '',
430+ view._renderAction('url', 'name', 'path', 'sprite', False))
431+
432+ def test_renderAction(self):
433+ # If enabled, _renderAction produces a link to an action form
434+ # for a given template.
435+ view = self._makeView()
436+
437+ url = self.factory.getUniqueString()
438+ name = self.factory.getUniqueString()
439+ path = self.factory.getUniqueString()
440+ sprite = self.factory.getUniqueString()
441+
442+ action = view._renderAction(url, name, path, sprite, True)
443+
444+ self.assertIn('<a ', action)
445+ self.assertIn('href="%s/%s"' % (url, path), action)
446+ self.assertIn(name, action)
447+ self.assertIn('class="sprite %s"' % sprite, action)
448+
449+ def test_renderField_returns_empty_string_if_no_content(self):
450+ view = self._makeView()
451+ self.assertEqual('', view._renderField('x', None, tag='y'))
452+
453+ def test_renderField_returns_empty_field_for_empty_content(self):
454+ field = self._makeView()._renderField('class', '', tag='tag')
455+ self.assertIn('<tag class="class">', field)
456+ self.assertIn('</tag>', field)
457+
458+ def test_renderField(self):
459+ column_class = self.factory.getUniqueString()
460+ content = self.factory.getUniqueString()
461+ tag = self.factory.getUniqueString()
462+
463+ field = self._makeView()._renderField(column_class, content, tag=tag)
464+
465+ self.assertIn('<%s class="%s">' % (tag, column_class), field)
466+ self.assertIn(content, field)
467+ self.assertIn('</%s>' % tag, field)
468+
469+ def test_renderTemplateRow(self):
470+ template = self._makeTemplate()
471+ view = self._makeView(template)
472+
473+ row = view.renderTemplateRow(template)
474+
475+ self.assertEqual(
476+ [sorted(['template_row', view.rowCSSClass(template)])],
477+ self._findTagClasses(row, 'tr'))
478+
479+ self.assertEqual(self.columns, self._findTagClasses(row, 'td'))
480+
481+
482+class TestDistroSeriesTemplatesView(SeriesTemplatesViewScenario,
483+ TestCaseWithFactory):
484+ """Run the test scenario against `DistroSeriesTemplatesView`."""
485+
486+ layer = DatabaseFunctionalLayer
487+
488+ view_class = DistroSeriesTemplatesView
489+
490+ columns = [
491+ ['priority_column'],
492+ ['sourcepackage_column'],
493+ ['template_column'],
494+ ['length_column'],
495+ ['lastupdate_column'],
496+ ]
497+
498+ def makeTemplateContext(self):
499+ """See `SeriesTemplatesViewScenario`."""
500+ return dict(
501+ sourcepackagename=self.factory.makeSourcePackageName(),
502+ distroseries=self.factory.makeDistroSeries())
503+
504+ def test_makeTemplate(self):
505+ # In this test case, _makeTemplate produces a distroseries
506+ # template.
507+ template = self._makeTemplate()
508+ self.assertIsInstance(template.distroseries, DistroSeries)
509+ self.assertIs(None, template.productseries)
510+
511+ def test_findTagClasses(self):
512+ # Tested here arbitrarily (no need to repeat it): the
513+ # _findTagClasses helper.
514+ self.assertEqual(
515+ [['b', 'c'], ['a']],
516+ self._findTagClasses('<x class="c b" /><x class="a">', 'x'))
517+
518+ def test_findActions(self):
519+ # Tested here arbitrarily (no need to repeat it): the
520+ # _findActions helper.
521+ self.assertEqual(['Foo'], self._findActions('<a class="bar">Foo</a>'))
522+
523+ def test_is_distroseries(self):
524+ self.assertTrue(self._makeView().is_distroseries)
525+
526+ def test_renderSourcePackage(self):
527+ # _renderSourcePackage returns the template's source-package
528+ # name for a productseries view.
529+ template = self._makeTemplate()
530+ view = self._makeView(template)
531+
532+ return self.assertEqual(
533+ template.sourcepackagename.name,
534+ view._renderSourcePackage(template))
535+
536+
537+class TestProductSeriesTemplatesView(SeriesTemplatesViewScenario,
538+ TestCaseWithFactory):
539+ """Run the test scenario against `ProductSeriesTemplatesView`."""
540+
541+ layer = DatabaseFunctionalLayer
542+
543+ view_class = ProductSeriesTemplatesView
544+
545+ columns = [
546+ ['priority_column'],
547+ ['template_column'],
548+ ['length_column'],
549+ ['lastupdate_column'],
550+ ]
551+
552+ def makeTemplateContext(self):
553+ """See `SeriesTemplatesViewScenario`."""
554+ return dict(productseries=self.factory.makeProductSeries())
555+
556+ def test_makeTemplate(self):
557+ # In this test case, _makeTemplate produces a productseries
558+ # template.
559+ template = self._makeTemplate()
560+ self.assertIs(None, template.distroseries)
561+ self.assertIsInstance(template.productseries, ProductSeries)
562+
563+ def test_is_distroseries(self):
564+ self.assertFalse(self._makeView().is_distroseries)
565+
566+ def test_renderSourcePackage(self):
567+ # _renderSourcePackage returns None for a productseries view.
568+ template = self._makeTemplate()
569+ view = self._makeView(template)
570+ self.assertIs(None, view._renderSourcePackage(template))
571
572=== modified file 'lib/lp/translations/stories/standalone/xx-potemplate-edit.txt'
573--- lib/lp/translations/stories/standalone/xx-potemplate-edit.txt 2010-04-22 17:07:56 +0000
574+++ lib/lp/translations/stories/standalone/xx-potemplate-edit.txt 2010-09-13 16:27:49 +0000
575@@ -183,9 +183,7 @@
576
577 >>> referrer = 'http://translations.launchpad.dev/evolution/trunk/+templates'
578 >>> admin_browser.open(referrer)
579- >>> admin_browser.getLink(
580- ... url='/evolution/trunk/+pots/'
581- ... 'evolution-2.2/+edit').click()
582+ >>> admin_browser.getLink(url='+pots/evolution-2.2/+edit').click()
583 >>> admin_browser.getControl('Change').click()
584 >>> admin_browser.url == referrer
585 True
586
587=== modified file 'lib/lp/translations/stories/standalone/xx-series-templates.txt'
588--- lib/lp/translations/stories/standalone/xx-series-templates.txt 2010-07-15 13:57:23 +0000
589+++ lib/lp/translations/stories/standalone/xx-series-templates.txt 2010-09-13 16:27:49 +0000
590@@ -85,16 +85,14 @@
591 >>> utc_browser.open(
592 ... 'http://translations.launchpad.dev/ubuntu/hoary/+templates')
593 >>> utc_browser.getLink(
594- ... url='ubuntu/hoary/+source/evolution/'
595- ... '+pots/evolution-2.2/+edit').click()
596+ ... url='+source/evolution/+pots/evolution-2.2/+edit').click()
597 >>> print utc_browser.url
598 http://.../ubuntu/hoary/+source/evolution/+pots/evolution-2.2/+edit
599
600 >>> utc_browser.open(
601 ... 'http://translations.launchpad.dev/ubuntu/hoary/+templates')
602 >>> utc_browser.getLink(
603- ... url='/ubuntu/hoary/+source/evolution/'
604- ... '+pots/evolution-2.2/+admin').click()
605+ ... url='+source/evolution/+pots/evolution-2.2/+admin').click()
606 >>> print utc_browser.url
607 http://.../ubuntu/hoary/+source/evolution/+pots/evolution-2.2/+admin
608
609@@ -104,16 +102,14 @@
610 >>> utc_browser.open(
611 ... 'http://translations.launchpad.dev/ubuntu/hoary/+templates')
612 >>> utc_browser.getLink(
613- ... url='ubuntu/hoary/+source/evolution/'
614- ... '+pots/disabled-template/+edit').click()
615+ ... url='+source/evolution/+pots/disabled-template/+edit').click()
616 >>> print utc_browser.url
617 http://.../ubuntu/hoary/+source/evolution/+pots/disabled-template/+edit
618
619 >>> utc_browser.open(
620 ... 'http://translations.launchpad.dev/ubuntu/hoary/+templates')
621 >>> utc_browser.getLink(
622- ... url='/ubuntu/hoary/+source/evolution/'
623- ... '+pots/disabled-template/+admin').click()
624+ ... url='+source/evolution/+pots/disabled-template/+admin').click()
625 >>> print utc_browser.url
626 http://.../ubuntu/hoary/+source/evolution/+pots/disabled-template/+admin
627
628
629=== modified file 'lib/lp/translations/templates/object-templates.pt'
630--- lib/lp/translations/templates/object-templates.pt 2010-07-15 09:41:27 +0000
631+++ lib/lp/translations/templates/object-templates.pt 2010-09-13 16:27:49 +0000
632@@ -73,80 +73,13 @@
633 <div class="yui-b">
634 <table class="sortable listing" id="templates_table">
635 <thead>
636- <tr>
637- <th class="priority_column">Priority</th>
638- <th tal:condition="view/is_distroseries"
639- class="sourcepackage_column">Source package</th>
640- <th class="template_column">Template name</th>
641- <th class="length_column">Length</th>
642- <th class="lastupdate_column">Updated</th>
643- <th class="actions_column"
644- tal:condition="context/required:launchpad.AnyPerson">
645- Actions</th>
646+ <tr tal:content="structure view/renderTemplatesHeader">
647 </tr>
648 </thead>
649 <tbody>
650- <tal:templates repeat="template view/iter_templates">
651- <tr tal:define="
652- inactive_css_class python:view.rowCSSClass(template);
653- template_url template/fmt:url"
654- tal:condition="python: view.can_edit or template.iscurrent"
655- tal:attributes="
656- class string: template_row ${inactive_css_class}">
657- <td class="priority_column"
658- tal:content="template/priority">7</td>
659- <td tal:condition="view/is_distroseries"
660- tal:content="template/sourcepackagename/name"
661- class="sourcepackage_column">Source package
662- </td>
663- <td class="template_column">
664- <a tal:attributes="href template_url">
665- <span tal:content="template/name">Template name</span>
666- </a>
667- <tal:inactive condition="not: template/iscurrent">
668- (inactive)
669- </tal:inactive>
670- </td>
671- <td class="length_column"
672- tal:content="template/messagecount">1777</td>
673- <td class="lastupdate_column">
674- <span class="sortkey"
675- tal:condition="template/date_last_updated"
676- tal:content="template/date_last_updated/fmt:datetime">
677- time sort key
678- </span>
679- <span class="lastupdate_column"
680- tal:condition="template/date_last_updated"
681- tal:attributes="
682- title template/date_last_updated/fmt:datetime"
683- tal:content="
684- template/date_last_updated/fmt:approximatedate"
685- >
686- 2009-09-23
687- </span>
688- </td>
689- <td class="actions_column"
690- tal:condition="context/required:launchpad.AnyPerson">
691- <div class="template_links">
692- <a class="sprite edit"
693- tal:condition="view/can_edit"
694- tal:attributes="
695- href string: ${template_url}/+edit">Edit</a>
696- <a class="sprite add"
697- tal:condition="view/can_edit"
698- tal:attributes="
699- href string: ${template_url}/+upload">Upload</a>
700- <a class="sprite download"
701- tal:attributes="
702- href string: ${template_url}/+export">Download</a>
703- <a class="sprite edit"
704- tal:condition="view/can_admin"
705- tal:attributes="
706- href string: ${template_url}/+admin">Administer</a>
707- </div>
708- </td>
709- </tr>
710- </tal:templates>
711+ <tal:templates
712+ repeat="template view/iter_templates"
713+ content="structure python:view.renderTemplateRow(template)"/>
714 </tbody>
715 </table>
716 </div>