Merge lp:~jtv/launchpad/bug-416434-aggregator into lp:launchpad

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Graham Binns
Approved revision: no longer in the source branch.
Merged at revision: not available
Proposed branch: lp:~jtv/launchpad/bug-416434-aggregator
Merge into: lp:launchpad
Diff against target: None lines
To merge this branch: bzr merge lp:~jtv/launchpad/bug-416434-aggregator
Reviewer Review Type Date Requested Status
Graham Binns (community) gmb Approve
Review via email: mp+10647@code.launchpad.net
To post a comment you must log in.
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :
Download full text (4.0 KiB)

= Translation Links Aggregator =

This branch is a bit overweight because it lifts code & tests out of a
view class and into a module of its own. It's preparatory work for the
"stuff to translate" addition to the Translations personal dashboard.

A recurring problem is this one: given a bunch of POFiles, what is a
sensible set of links to present them to the user? One extreme answer
would be to link to (and describe) each individually, but we're trying
to build a more sensible UI with less pointless information for the
viewer to plod through.

At the other extreme, we could link to the largest single thing in
Launchpad that represents all of the translations: POFile; POTemplate;
ProductSeries or SourcePackage; Product or Distribution; or even all of
Launchpad Translations. The more extreme it gets, the less sensible it
becomes. We like to avoid "false positives" where lots of the stuff we
link to is not actually in the original list of translations.

The TranslationLinksAggregator, introduced here, tries to find a happy
medium. It breaks your set of translations down by ProductSeries or
SourcePackage, and within each of those, it tries to find a good common
link to represent all the inputs without too many false positives. If
it finds no really good single link, it links to the individual POFiles.

You use this new class by inheriting from it and overloading "describe."
This method receives all relevant information about a translation target
and turns it into a description suitable for use by the UI. The
description can take whatever form the subclass likes, making the
aggregator fairly flexible.

(Description is the very last stage of aggregation and I could have left
it out. I could have made the aggregator produce a sequence of tuples,
each with the arguments that now are passed to the describe method. The
caller could then process each of these in any way desired. But in my
experience passing lists of tuples around soon affects maintainability;
turning them into classes on the other hand leads to obesity).

As a free extra, the aggregator supports not just POFiles but
POTemplates as well. In some ways they are like "POFiles without a
language." Danilo reviewed the branch that first introduced this stuff
and found that it solved a very similar problem to one he faced with
translation exports. One of the main differences was that in his case
there might be templates as well as translations. It was easy enough to
add, and hopefully this means that the class will serve his needs as
well.

The hard part about this branch is that it breaks the unit tests for the
PersonTranslationView into two: a new one for the aggregator, and the
gutted remains of the view test proper. Some detail in the unit-testing
will be lost, and a bit of additional high-level testing makes up for
it.

== Tests, demo & Q/A ==

Test with:
{{{
./bin/test -vv -t persontranslationview
./bin/test -vv -t translationlinksaggregator
}}}

To demo and Q/A, look at the home page for someone who is in a
translation group, under the Translations tab. If that person is you,
you'll see a list of translations suggested for review. That list will
now be aggregated using t...

Read more...

Revision history for this message
Graham Binns (gmb) :
review: Approve (gmb)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/translations/browser/person.py'
2--- lib/lp/translations/browser/person.py 2009-08-19 14:25:32 +0000
3+++ lib/lp/translations/browser/person.py 2009-08-25 10:44:02 +0000
4@@ -26,6 +26,9 @@
5 from canonical.launchpad.webapp.publisher import LaunchpadView
6 from canonical.widgets import LaunchpadRadioWidget
7 from lp.registry.interfaces.person import IPerson
8+from lp.registry.interfaces.sourcepackage import ISourcePackage
9+from lp.translations.browser.translationlinksaggregator import (
10+ TranslationLinksAggregator)
11 from lp.translations.interfaces.pofiletranslator import (
12 IPOFileTranslatorSet)
13 from lp.translations.interfaces.translationrelicensingagreement import (
14@@ -33,7 +36,47 @@
15 TranslationRelicensingAgreementOptions)
16 from lp.translations.interfaces.translationsperson import (
17 ITranslationsPerson)
18-from lp.translations.model.productserieslanguage import ProductSeriesLanguage
19+
20+
21+class WorkListLinksAggregator(TranslationLinksAggregator):
22+ """Aggregate translation links for translation or review.
23+
24+ Here, all files are actually `POFile`s, never `POTemplate`s.
25+ """
26+
27+ def countStrings(self, pofile):
28+ """Count number of strings that need work."""
29+ raise NotImplementedError()
30+
31+ def describe(self, target, link, covered_files):
32+ """See `TranslationLinksAggregator.describe`."""
33+ strings_count = sum(
34+ [self.countStrings(pofile) for pofile in covered_files])
35+
36+ if strings_count == 1:
37+ strings_wording = "%d string"
38+ else:
39+ strings_wording = "%d strings"
40+
41+ return {
42+ 'target': target,
43+ 'count': strings_count,
44+ 'count_wording': strings_wording % strings_count,
45+ 'is_product': not ISourcePackage.providedBy(target),
46+ 'link': link,
47+ }
48+
49+
50+class ReviewLinksAggregator(WorkListLinksAggregator):
51+ """A `TranslationLinksAggregator` for translations to review."""
52+
53+ # Link to unreviewed suggestions.
54+ pofile_link_suffix = '/+translate?show=new_suggestions'
55+
56+ # Strings that need work are ones with unreviewed suggestions.
57+ def countStrings(self, pofile):
58+ """See `WorkListLinksAggregator.countStrings`."""
59+ return pofile.unreviewedCount()
60
61
62 class PersonTranslationsMenu(NavigationMenu):
63@@ -98,6 +141,20 @@
64 """Is this person in a translation group?"""
65 return len(self.translation_groups) != 0
66
67+ @property
68+ def person_is_translator(self):
69+ """Is this person active in translations?"""
70+ return self.context.hasKarma('translations')
71+
72+ @property
73+ def person_includes_me(self):
74+ """Is the current user (a member of) this person?"""
75+ user = getUtility(ILaunchBag).user
76+ if user is None:
77+ return False
78+ else:
79+ return user.inTeam(self.context)
80+
81 def should_display_message(self, translationmessage):
82 """Should a certain `TranslationMessage` be displayed.
83
84@@ -111,167 +168,51 @@
85 return not (
86 translationmessage.potmsgset.hide_translations_from_anonymous)
87
88- def _composeReviewLinks(self, pofiles):
89- """Compose URLs for reviewing given `POFile`s."""
90- return [
91- canonical_url(pofile) + '/+translate?show=new_suggestions'
92- for pofile in pofiles
93- ]
94-
95- def _findBestCommonReviewLinks(self, pofiles):
96- """Find best links to a bunch of related `POFile`s.
97-
98- The `POFile`s must either be in the same `Product`, or in the
99- same `DistroSeries` and `SourcePackageName`.
100-
101- This method finds the greatest common denominators between them,
102- and returns a list of links to them: the individual translation
103- if there is only one, the template if multiple translations of
104- one template are involved, and so on.
105- """
106- assert pofiles, "Empty POFiles list in reviewable target."
107- first_pofile = pofiles[0]
108-
109- if len(pofiles) == 1:
110- # Simple case: one translation file. Go straight to
111- # translation page for its unreviewed strings.
112- return self._composeReviewLinks(pofiles)
113-
114- templates = set(pofile.potemplate for pofile in pofiles)
115-
116- productseries = set(
117- template.productseries
118- for template in templates
119- if template.productseries)
120-
121- products = set(series.product for series in productseries)
122-
123- sourcepackagenames = set(
124- template.sourcepackagename
125- for template in templates
126- if template.sourcepackagename)
127-
128- distroseries = set(
129- template.distroseries
130- for template in templates
131- if template.distroseries)
132-
133- assert len(products) <= 1, "Got more than one product."
134- assert len(sourcepackagenames) <= 1, "Got more than one package."
135- assert len(distroseries) <= 1, "Got more than one distroseries."
136- assert len(products) + len(sourcepackagenames) == 1, (
137- "Didn't get POFiles for exactly one package or one product.")
138-
139- first_template = first_pofile.potemplate
140-
141- if len(templates) == 1:
142- # Multiple translations for one template. Link to the
143- # template.
144- return [canonical_url(first_template)]
145-
146- if sourcepackagenames:
147- # Multiple POFiles for a source package. Show its template
148- # listing.
149- distroseries = first_template.distroseries
150- packagename = first_template.sourcepackagename
151- return [canonical_url(distroseries.getSourcePackage(packagename))]
152-
153- if len(productseries) == 1:
154- series = first_template.productseries
155- # All for the same ProductSeries.
156- languages = set(pofile.language for pofile in pofiles)
157- if len(languages) == 1:
158- # All for the same language in the same ProductSeries,
159- # but apparently for different templates. Link to
160- # ProductSeriesLanguage.
161- productserieslanguage = ProductSeriesLanguage(
162- series, pofiles[0].language)
163- return [canonical_url(productserieslanguage)]
164- else:
165- # Multiple templates and languages in the same product
166- # series. Show its templates listing.
167- return [canonical_url(series)]
168-
169- # Different release series of the same product. Link to each of
170- # the individual POFiles.
171- return self._composeReviewLinks(pofiles)
172-
173- def _describeReviewableTarget(self, target, link, strings_count):
174- """Produce dict to describe a reviewable target.
175-
176- The target may be a `Product` or a tuple of `SourcePackageName`
177- and `DistroSeries`.
178- """
179- if isinstance(target, tuple):
180- (name, distroseries) = target
181- target = distroseries.getSourcePackage(name)
182- is_product = False
183- else:
184- is_product = True
185-
186- if strings_count == 1:
187- strings_wording = '%d string'
188- else:
189- strings_wording = '%d strings'
190-
191- return {
192- 'target': target,
193- 'count': strings_count,
194- 'link': link,
195- 'count_wording': strings_wording % strings_count,
196- 'is_product': is_product,
197- }
198-
199 def _setHistoryHorizon(self):
200 """If not already set, set `self.history_horizon`."""
201 if self.history_horizon is None:
202 now = datetime.now(pytz.timezone('UTC'))
203 self.history_horizon = now - timedelta(90, 0, 0)
204
205- def _aggregateTranslationTargets(self, pofiles):
206- """Aggregate list of `POFile`s into sensible targets.
207-
208- Returns a list of target descriptions as returned by
209- `_describeReviewableTarget` after going through
210- `_findBestCommonReviewLinks`.
211- """
212- targets = {}
213- for pofile in pofiles:
214- template = pofile.potemplate
215- if template.productseries:
216- target = template.productseries.product
217- else:
218- target = (template.sourcepackagename, template.distroseries)
219-
220- if target in targets:
221- (count, target_pofiles) = targets[target]
222- else:
223- count = 0
224- target_pofiles = []
225-
226- count += pofile.unreviewedCount()
227- target_pofiles.append(pofile)
228-
229- targets[target] = (count, target_pofiles)
230-
231- result = []
232- for target, stats in targets.iteritems():
233- (count, target_pofiles) = stats
234- links = self._findBestCommonReviewLinks(target_pofiles)
235- for link in links:
236- result.append(
237- self._describeReviewableTarget(target, link, count))
238-
239- return result
240+ def _getTargetsForReview(self, max_fetch=None):
241+ """Query and aggregate the top targets for review.
242+
243+ :param max_fetch: Maximum number of `POFile`s to fetch while
244+ looking for these.
245+ :return: a list of at most `max_fetch` translation targets.
246+ Multiple `POFile`s may be aggregated together into a single
247+ target.
248+ """
249+ self._setHistoryHorizon()
250+ person = ITranslationsPerson(self.context)
251+ pofiles = person.getReviewableTranslationFiles(
252+ no_older_than=self.history_horizon)
253+
254+ if max_fetch is not None:
255+ pofiles = pofiles[:max_fetch]
256+
257+ return ReviewLinksAggregator().aggregate(pofiles)
258+
259+ def _suggestTargetsForReview(self, max_fetch):
260+ """Find random translation targets for review.
261+
262+ :param max_fetch: Maximum number of `POFile`s to fetch while
263+ looking for these.
264+ :return: a list of at most `max_fetch` translation targets.
265+ Multiple `POFile`s may be aggregated together into a single
266+ target.
267+ """
268+ self._setHistoryHorizon()
269+ person = ITranslationsPerson(self.context)
270+ pofiles = person.suggestReviewableTranslationFiles(
271+ no_older_than=self.history_horizon)[:max_fetch]
272+
273+ return ReviewLinksAggregator().aggregate(pofiles)
274
275 @cachedproperty
276 def all_projects_and_packages_to_review(self):
277 """Top projects and packages for this person to review."""
278- self._setHistoryHorizon()
279- person = ITranslationsPerson(self.context)
280- return self._aggregateTranslationTargets(
281- person.getReviewableTranslationFiles(
282- no_older_than=self.history_horizon))
283+ return self._getTargetsForReview()
284
285 @property
286 def top_projects_and_packages_to_review(self):
287@@ -280,42 +221,30 @@
288
289 # Maximum number of projects/packages to list that this person
290 # has recently worked on.
291- max_old_targets = 9
292+ max_known_targets = 9
293 # Length of overall list to display.
294 list_length = 10
295
296 # Start out with the translations that the person has recently
297 # worked on. Aggregation may reduce the number we get, so ask
298 # the database for a few extra.
299- fetch = 5 * max_old_targets
300- recent = self.all_projects_and_packages_to_review[:fetch]
301+ fetch = 5 * max_known_targets
302+ recent = self._getTargetsForReview(fetch)[:max_known_targets]
303
304 # Fill out the list with other translations that the person
305 # could also be reviewing.
306- empty_slots = list_length - min(len(recent), max_old_targets)
307+ empty_slots = list_length - len(recent)
308 fetch = 5 * empty_slots
309-
310- person = ITranslationsPerson(self.context)
311- random_suggestions = self._aggregateTranslationTargets(
312- person.suggestReviewableTranslationFiles(
313- no_older_than=self.history_horizon)[:fetch])
314-
315- return recent[:max_old_targets] + random_suggestions[:empty_slots]
316+ random_suggestions = self._suggestTargetsForReview(fetch)
317+ random_suggestions = random_suggestions[:empty_slots]
318+
319+ return recent + random_suggestions
320
321 @cachedproperty
322 def num_projects_and_packages_to_review(self):
323 """How many translations do we suggest for reviewing?"""
324 return len(self.all_projects_and_packages_to_review)
325
326- @property
327- def person_includes_me(self):
328- """Is the current user (a member of) this person?"""
329- user = getUtility(ILaunchBag).user
330- if user is None:
331- return False
332- else:
333- return user.inTeam(self.context)
334-
335
336 class PersonTranslationRelicensingView(LaunchpadFormView):
337 """View for Person's translation relicensing page."""
338
339=== modified file 'lib/lp/translations/browser/tests/test_persontranslationview.py'
340--- lib/lp/translations/browser/tests/test_persontranslationview.py 2009-08-19 15:20:40 +0000
341+++ lib/lp/translations/browser/tests/test_persontranslationview.py 2009-08-25 10:26:28 +0000
342@@ -14,7 +14,6 @@
343 from canonical.launchpad.webapp.servers import LaunchpadTestRequest
344 from lp.services.worlddata.model.language import LanguageSet
345 from lp.translations.browser.person import PersonTranslationView
346-from lp.translations.model.productserieslanguage import ProductSeriesLanguage
347 from lp.translations.model.translator import TranslatorSet
348
349
350@@ -78,251 +77,6 @@
351 self._makeReviewer()
352 self.assertTrue(self.view.person_is_reviewer)
353
354- def test_findBestCommonReviewLinks_single_pofile(self):
355- # If passed a single POFile, _findBestCommonReviewLinks returns
356- # a list of just that POFile.
357- pofile = self.factory.makePOFile(language_code='lua')
358- links = self.view._findBestCommonReviewLinks([pofile])
359- self.assertEqual(self.view._composeReviewLinks([pofile]), links)
360-
361- def test_findBestCommonReviewLinks_product_wild_mix(self):
362- # A combination of wildly different POFiles in the same product
363- # yields links to the individual POFiles.
364- pofile1 = self.factory.makePOFile(language_code='sux')
365- product = pofile1.potemplate.productseries.product
366- series2 = self.factory.makeProductSeries(product)
367- template2 = self.factory.makePOTemplate(productseries=series2)
368- pofile2 = self.factory.makePOFile(
369- potemplate=template2, language_code='la')
370-
371- links = self.view._findBestCommonReviewLinks([pofile1, pofile2])
372-
373- expected_links = self.view._composeReviewLinks([pofile1, pofile2])
374- self.assertEqual(expected_links, links)
375-
376- def test_findBestCommonReviewLinks_different_templates(self):
377- # A combination of POFiles in the same language but different
378- # templates of the same productseries is represented as a link
379- # to the ProductSeriesLanguage.
380- pofile1 = self.factory.makePOFile(language_code='nl')
381- series = pofile1.potemplate.productseries
382- template2 = self.factory.makePOTemplate(productseries=series)
383- pofile2 = self.factory.makePOFile(
384- potemplate=template2, language_code='nl')
385-
386- links = self.view._findBestCommonReviewLinks([pofile1, pofile2])
387-
388- productserieslanguage = ProductSeriesLanguage(
389- series, pofile1.language)
390- self.assertEqual([canonical_url(productserieslanguage)], links)
391-
392- def test_findBestCommonReviewLinks_different_languages(self):
393- # If the POFiles differ only in language, we get a link to the
394- # overview for the template.
395- pofile1 = self.factory.makePOFile(language_code='nl')
396- template = pofile1.potemplate
397- pofile2 = self.factory.makePOFile(
398- potemplate=template, language_code='lo')
399-
400- links = self.view._findBestCommonReviewLinks([pofile1, pofile2])
401-
402- self.assertEqual([canonical_url(template)], links)
403-
404- def test_findBestCommonReviewLinks_sharing_pofiles(self):
405- # In a Product, two POFiles may share their translations. For
406- # now, we link to each individually. We may want to make this
407- # more clever in the future.
408- pofile1 = self.factory.makePOFile(language_code='nl')
409- template1 = pofile1.potemplate
410- series1 = template1.productseries
411- series2 = self.factory.makeProductSeries(product=series1.product)
412- template2 = self.factory.makePOTemplate(
413- productseries=series2, name=template1.name,
414- translation_domain=template1.translation_domain)
415- pofile2 = template2.getPOFileByLang('nl')
416-
417- pofiles = [pofile1, pofile2]
418- links = self.view._findBestCommonReviewLinks(pofiles)
419-
420- self.assertEqual(self.view._composeReviewLinks(pofiles), links)
421-
422- def test_findBestCommonReviewLinks_package_different_languages(self):
423- # For package POFiles in the same template but different
424- # languages, we link to the template.
425- package = self.factory.makeSourcePackage()
426- package.distroseries.distribution.official_rosetta = True
427- template = self.factory.makePOTemplate(
428- distroseries=package.distroseries,
429- sourcepackagename=package.sourcepackagename)
430- pofile1 = self.factory.makePOFile(
431- potemplate=template, language_code='nl')
432- pofile2 = self.factory.makePOFile(
433- potemplate=template, language_code='ka')
434-
435- links = self.view._findBestCommonReviewLinks([pofile1, pofile2])
436- self.assertEqual([canonical_url(template)], links)
437-
438- def test_findBestCommonReviewLinks_package_different_templates(self):
439- # For package POFiles in different templates, we to the
440- # package's template list. There is no "source package series
441- # language" page.
442- package = self.factory.makeSourcePackage()
443- package.distroseries.distribution.official_rosetta = True
444- template1 = self.factory.makePOTemplate(
445- distroseries=package.distroseries,
446- sourcepackagename=package.sourcepackagename)
447- template2 = self.factory.makePOTemplate(
448- distroseries=package.distroseries,
449- sourcepackagename=package.sourcepackagename)
450- pofile1 = self.factory.makePOFile(
451- potemplate=template1, language_code='nl')
452- pofile2 = self.factory.makePOFile(
453- potemplate=template2, language_code='nl')
454-
455- links = self.view._findBestCommonReviewLinks([pofile1, pofile2])
456-
457- self.assertEqual([canonical_url(package)], links)
458-
459- def test_describeReviewableTarget_string_count(self):
460- # _describeReviewableTarget puts out a human-readable
461- # description of how many strings need review.
462- product = self.factory.makeProduct()
463-
464- description = self.view._describeReviewableTarget(
465- product, canonical_url(product), 0)
466- self.assertEqual(description['count'], 0)
467- self.assertEqual(description['count_wording'], '0 strings')
468-
469- # Singular applies for exactly 1 string.
470- description = self.view._describeReviewableTarget(
471- product, canonical_url(product), 1)
472- self.assertEqual(description['count'], 1)
473- self.assertEqual(description['count_wording'], '1 string')
474-
475-
476- description = self.view._describeReviewableTarget(
477- product, canonical_url(product), 2)
478- self.assertEqual(description['count'], 2)
479- self.assertEqual(description['count_wording'], '2 strings')
480-
481- def test_describeReviewableTarget_product(self):
482- # _describeReviewableTarget describes a Product with reviewable
483- # translations.
484- product = self.factory.makeProduct()
485- link = canonical_url(product)
486-
487- description = self.view._describeReviewableTarget(product, link, 99)
488-
489- expected_description = {
490- 'target': product,
491- 'count': 99,
492- 'count_wording': "99 strings",
493- 'is_product': True,
494- 'link': link,
495- }
496- self.assertEqual(expected_description, description)
497-
498- def test_describeReviewableTarget_package(self):
499- # _describeReviewableTarget describes a package with reviewable
500- # translations.
501- package = self.factory.makeSourcePackage()
502- package.distroseries.distribution.official_rosetta = True
503- target = (package.sourcepackagename, package.distroseries)
504- link = canonical_url(package)
505-
506- description = self.view._describeReviewableTarget(target, link, 42)
507-
508- expected_description = {
509- 'target': package,
510- 'count': 42,
511- 'count_wording': "42 strings",
512- 'is_product': False,
513- 'link': link,
514- }
515- self.assertEqual(expected_description, description)
516-
517- def test_aggregateTranslationTargets(self):
518- # _aggregateTranslationTargets represents a series of POFiles as
519- # a series of target descriptions, aggregating where possible.
520-
521- # Trivial case: no POFiles means no targets.
522- self.assertEqual([], self.view._aggregateTranslationTargets([]))
523-
524- # Basic case: one POFile yields its product or package.
525- pofile = self.factory.makePOFile(language_code='ca')
526-
527- description = self.view._aggregateTranslationTargets([pofile])
528-
529- expected_links = self.view._composeReviewLinks([pofile])
530- expected_description = [{
531- 'target': pofile.potemplate.productseries.product,
532- 'count': 0,
533- 'count_wording': "0 strings",
534- 'is_product': True,
535- 'link': expected_links[0],
536- }]
537- self.assertEqual(expected_description, description)
538-
539- def test_aggregateTranslationTargets_product_and_package(self):
540- # _aggregateTranslationTargets keeps a product and a package
541- # separate.
542- product_pofile = self.factory.makePOFile(language_code='th')
543- removeSecurityProxy(product_pofile).unreviewed_count = 1
544-
545- package = self.factory.makeSourcePackage()
546- package.distroseries.distribution.official_rosetta = True
547- package_template = self.factory.makePOTemplate(
548- distroseries=package.distroseries,
549- sourcepackagename=package.sourcepackagename)
550- package_pofile = self.factory.makePOFile(
551- potemplate=package_template, language_code='th')
552- removeSecurityProxy(package_pofile).unreviewed_count = 2
553-
554- descriptions = self.view._aggregateTranslationTargets(
555- [product_pofile, package_pofile])
556- links = set(entry['link'] for entry in descriptions)
557-
558- expected_links = set(
559- self.view._composeReviewLinks([product_pofile, package_pofile]))
560- self.assertEqual(expected_links, links)
561-
562- def test_aggregateTranslationTargets_bundles_productseries(self):
563- # _aggregateTranslationTargets describes POFiles for the same
564- # ProductSeries together.
565- pofile1 = self.factory.makePOFile(language_code='es')
566- series = pofile1.potemplate.productseries
567- template2 = self.factory.makePOTemplate(productseries=series)
568- pofile2 = self.factory.makePOFile(
569- language_code='br', potemplate=template2)
570-
571- description = self.view._aggregateTranslationTargets(
572- [pofile1, pofile2])
573-
574- self.assertEqual(1, len(description))
575- self.assertEqual(canonical_url(series), description[0]['link'])
576-
577- def test_aggregateTranslationTargets_bundles_package(self):
578- # _aggregateTranslationTargets describes POFiles for the same
579- # ProductSeries together.
580- package = self.factory.makeSourcePackage()
581- package.distroseries.distribution.official_rosetta = True
582- template1 = self.factory.makePOTemplate(
583- distroseries=package.distroseries,
584- sourcepackagename=package.sourcepackagename)
585- pofile1 = self.factory.makePOFile(
586- language_code='es', potemplate=template1)
587- template2 = self.factory.makePOTemplate(
588- distroseries=package.distroseries,
589- sourcepackagename=package.sourcepackagename)
590- pofile2 = self.factory.makePOFile(
591- language_code='br', potemplate=template2)
592-
593- description = self.view._aggregateTranslationTargets(
594- [pofile1, pofile2])
595-
596- self.assertEqual(1, len(description))
597- self.assertEqual(canonical_url(package), description[0]['link'])
598-
599 def test_num_projects_and_packages_to_review(self):
600 # num_projects_and_packages_to_review counts the number of
601 # reviewable targets that the person has worked on.
602@@ -332,6 +86,50 @@
603
604 self.assertEqual(1, self.view.num_projects_and_packages_to_review)
605
606+ def test_all_projects_and_packages_to_review_one(self):
607+ # all_projects_and_packages describes the translations available
608+ # for review by its person.
609+ self._makeReviewer()
610+ pofile = self._makePOFiles(1, previously_worked_on=True)[0]
611+ product = pofile.potemplate.productseries.product
612+
613+ descriptions = self.view.all_projects_and_packages_to_review
614+
615+ self.assertEqual(1, len(descriptions))
616+ self.assertEqual(product, descriptions[0]['target'])
617+
618+ def test_all_projects_and_packages_to_review_none(self):
619+ # all_projects_and_packages_to_review works even if there is
620+ # nothing to review. It will find nothing.
621+ self._makeReviewer()
622+
623+ descriptions = self.view.all_projects_and_packages_to_review
624+
625+ self.assertEqual([], descriptions)
626+
627+ def test_all_projects_and_packages_to_review_string_singular(self):
628+ # A translation description says how many strings need review,
629+ # both as a number and as text.
630+ self._makeReviewer()
631+ pofile = self._makePOFiles(1, previously_worked_on=True)[0]
632+ removeSecurityProxy(pofile).unreviewed_count = 1
633+
634+ description = self.view.all_projects_and_packages_to_review[0]
635+
636+ self.assertEqual(1, description['count'])
637+ self.assertEqual("1 string", description['count_wording'])
638+
639+ def test_all_projects_and_packages_to_review_string_plural(self):
640+ # For multiple strings, count_wording uses the plural.
641+ self._makeReviewer()
642+ pofile = self._makePOFiles(1, previously_worked_on=True)[0]
643+ removeSecurityProxy(pofile).unreviewed_count = 2
644+
645+ description = self.view.all_projects_and_packages_to_review[0]
646+
647+ self.assertEqual(2, description['count'])
648+ self.assertEqual("2 strings", description['count_wording'])
649+
650 def test_num_projects_and_packages_to_review_zero(self):
651 # num_projects_and_packages does not count new suggestions.
652 self._makeReviewer()
653@@ -351,8 +149,11 @@
654
655 targets = self.view.top_projects_and_packages_to_review
656
657- expected_links = self.view._composeReviewLinks(
658- [pofile_worked_on, pofile_not_worked_on])
659+ pofile_suffix = '/+translate?show=new_suggestions'
660+ expected_links = [
661+ canonical_url(pofile_worked_on) + pofile_suffix,
662+ canonical_url(pofile_not_worked_on) + pofile_suffix,
663+ ]
664 self.assertEqual(
665 set(expected_links), set(item['link'] for item in targets))
666
667
668=== added file 'lib/lp/translations/browser/tests/test_translationlinksaggregator.py'
669--- lib/lp/translations/browser/tests/test_translationlinksaggregator.py 1970-01-01 00:00:00 +0000
670+++ lib/lp/translations/browser/tests/test_translationlinksaggregator.py 2009-08-25 10:35:29 +0000
671@@ -0,0 +1,269 @@
672+# Copyright 2009 Canonical Ltd. This software is licensed under the
673+# GNU Affero General Public License version 3 (see the file LICENSE).
674+
675+__metaclass__ = type
676+
677+from unittest import TestLoader
678+
679+from zope.security.proxy import removeSecurityProxy
680+
681+from lp.testing import TestCaseWithFactory
682+from canonical.testing import LaunchpadZopelessLayer
683+
684+from canonical.launchpad.webapp import canonical_url
685+from lp.translations.browser.translationlinksaggregator import (
686+ TranslationLinksAggregator)
687+from lp.translations.model.productserieslanguage import ProductSeriesLanguage
688+
689+
690+class DumbAggregator(TranslationLinksAggregator):
691+ """A very simple `TranslationLinksAggregator`.
692+
693+ The `describe` method returns a tuple of its arguments.
694+ """
695+ def describe(self, target, link, covered_sheets):
696+ """See `TranslationLinksAggregator`."""
697+ return (target, link, covered_sheets)
698+
699+
700+def map_link(link_target, sheets=None, add_to=None):
701+ """Map a link the way _circumscribe does.
702+
703+ :param link_target: The object to link to. Its URL will be used.
704+ :param sheets: A list of POFiles and/or POTemplates. The link will
705+ map to these. If omitted, the list will consist of
706+ `link_target` itself.
707+ :param add_to: Optional existing dict to add the new entry to.
708+ :return: A dict mapping the URL for link_target to sheets.
709+ """
710+ if add_to is None:
711+ add_to = {}
712+ if sheets is None:
713+ sheets = [link_target]
714+ add_to[canonical_url(link_target)] = sheets
715+ return add_to
716+
717+
718+class TestTranslationLinksAggregator(TestCaseWithFactory):
719+ """Test `TranslationLinksAggregator`."""
720+
721+ layer = LaunchpadZopelessLayer
722+
723+ def setUp(self):
724+ super(TestTranslationLinksAggregator, self).setUp()
725+ self.aggregator = DumbAggregator()
726+
727+ def test_circumscribe_single_pofile(self):
728+ # If passed a single POFile, _circumscribe returns a list of
729+ # just that POFile.
730+ pofile = self.factory.makePOFile(language_code='lua')
731+
732+ links = self.aggregator._circumscribe([pofile])
733+
734+ self.assertEqual(map_link(pofile), links)
735+
736+ def test_circumscribe_product_wild_mix(self):
737+ # A combination of wildly different POFiles in the same product
738+ # yields links to the individual POFiles.
739+ pofile1 = self.factory.makePOFile(language_code='sux')
740+ product = pofile1.potemplate.productseries.product
741+ series2 = self.factory.makeProductSeries(product)
742+ template2 = self.factory.makePOTemplate(productseries=series2)
743+ pofile2 = self.factory.makePOFile(
744+ potemplate=template2, language_code='la')
745+
746+ links = self.aggregator._circumscribe([pofile1, pofile2])
747+
748+ expected_links = map_link(pofile1)
749+ expected_links = map_link(pofile2, add_to=expected_links)
750+ self.assertEqual(expected_links, links)
751+
752+ def test_circumscribe_different_templates(self):
753+ # A combination of POFiles in the same language but different
754+ # templates of the same productseries is represented as a link
755+ # to the ProductSeriesLanguage.
756+ pofile1 = self.factory.makePOFile(language_code='nl')
757+ series = pofile1.potemplate.productseries
758+ template2 = self.factory.makePOTemplate(productseries=series)
759+ pofile2 = self.factory.makePOFile(
760+ potemplate=template2, language_code='nl')
761+
762+ links = self.aggregator._circumscribe([pofile1, pofile2])
763+
764+ psl = ProductSeriesLanguage(series, pofile1.language)
765+ self.assertEqual(map_link(psl, [pofile1, pofile2]), links)
766+
767+ def test_circumscribe_different_languages(self):
768+ # If the POFiles differ only in language, we get a link to the
769+ # overview for the template.
770+ pofile1 = self.factory.makePOFile(language_code='nl')
771+ template = pofile1.potemplate
772+ pofile2 = self.factory.makePOFile(
773+ potemplate=template, language_code='lo')
774+
775+ pofiles = [pofile1, pofile2]
776+ links = self.aggregator._circumscribe(pofiles)
777+
778+ self.assertEqual(map_link(template, pofiles), links)
779+
780+ def test_circumscribe_sharing_pofiles(self):
781+ # In a Product, two POFiles may share their translations. For
782+ # now, we link to each individually. We may want to make this
783+ # more clever in the future.
784+ pofile1 = self.factory.makePOFile(language_code='nl')
785+ template1 = pofile1.potemplate
786+ series1 = template1.productseries
787+ series2 = self.factory.makeProductSeries(product=series1.product)
788+ template2 = self.factory.makePOTemplate(
789+ productseries=series2, name=template1.name,
790+ translation_domain=template1.translation_domain)
791+ pofile2 = template2.getPOFileByLang('nl')
792+
793+ pofiles = [pofile1, pofile2]
794+ links = self.aggregator._circumscribe(pofiles)
795+
796+ expected_links = map_link(pofile1)
797+ expected_links = map_link(pofile2, add_to=expected_links)
798+ self.assertEqual(expected_links, links)
799+
800+ def test_circumscribe_package_different_languages(self):
801+ # For package POFiles in the same template but different
802+ # languages, we link to the template.
803+ package = self.factory.makeSourcePackage()
804+ package.distroseries.distribution.official_rosetta = True
805+ template = self.factory.makePOTemplate(
806+ distroseries=package.distroseries,
807+ sourcepackagename=package.sourcepackagename)
808+ pofile1 = self.factory.makePOFile(
809+ potemplate=template, language_code='nl')
810+ pofile2 = self.factory.makePOFile(
811+ potemplate=template, language_code='ka')
812+
813+ pofiles = [pofile1, pofile2]
814+ links = self.aggregator._circumscribe(pofiles)
815+ self.assertEqual(map_link(template, pofiles), links)
816+
817+ def test_circumscribe_package_different_templates(self):
818+ # For package POFiles in different templates, we to the
819+ # package's template list. There is no "source package series
820+ # language" page.
821+ package = self.factory.makeSourcePackage()
822+ package.distroseries.distribution.official_rosetta = True
823+ template1 = self.factory.makePOTemplate(
824+ distroseries=package.distroseries,
825+ sourcepackagename=package.sourcepackagename)
826+ template2 = self.factory.makePOTemplate(
827+ distroseries=package.distroseries,
828+ sourcepackagename=package.sourcepackagename)
829+ pofile1 = self.factory.makePOFile(
830+ potemplate=template1, language_code='nl')
831+ pofile2 = self.factory.makePOFile(
832+ potemplate=template2, language_code='nl')
833+
834+ pofiles = [pofile1, pofile2]
835+ links = self.aggregator._circumscribe(pofiles)
836+
837+ self.assertEqual(map_link(package, pofiles), links)
838+
839+ def test_circumscribe_pofile_plus_template(self):
840+ # A template circumscribes both itself and any of its
841+ # translations.
842+ pofile = self.factory.makePOFile(language_code='uga')
843+ template = pofile.potemplate
844+
845+ sheets = [pofile, template]
846+ links = self.aggregator._circumscribe(sheets)
847+
848+ self.assertEqual(map_link(template, sheets), links)
849+
850+ def test_aggregate(self):
851+ # The aggregator represents a series of POFiles as a series of
852+ # target descriptions, aggregating where possible.
853+
854+ # Trivial case: no POFiles means no targets.
855+ self.assertEqual([], self.aggregator.aggregate([]))
856+
857+ # Basic case: one POFile yields its product or package.
858+ pofile = self.factory.makePOFile(language_code='ca')
859+ product = pofile.potemplate.productseries.product
860+
861+ descriptions = self.aggregator.aggregate([pofile])
862+
863+ expected = [(product, canonical_url(pofile), [pofile])]
864+ self.assertEqual(expected, descriptions)
865+
866+ def test_aggregate_potemplate(self):
867+ # Besides POFiles, you can also feed an aggregator POTemplates.
868+ template = self.factory.makePOTemplate()
869+ product = template.productseries.product
870+
871+ descriptions = self.aggregator.aggregate([template])
872+
873+ expected = [(product, canonical_url(template), [template])]
874+ self.assertEqual(expected, descriptions)
875+
876+ def test_aggregate_product_and_package(self):
877+ # The aggregator keeps a product and a package separate.
878+ product_pofile = self.factory.makePOFile(language_code='th')
879+ product = product_pofile.potemplate.productseries.product
880+ removeSecurityProxy(product_pofile).unreviewed_count = 1
881+
882+ package = self.factory.makeSourcePackage()
883+ package.distroseries.distribution.official_rosetta = True
884+ package_template = self.factory.makePOTemplate(
885+ distroseries=package.distroseries,
886+ sourcepackagename=package.sourcepackagename)
887+ package_pofile = self.factory.makePOFile(
888+ potemplate=package_template, language_code='th')
889+ removeSecurityProxy(package_pofile).unreviewed_count = 2
890+
891+ descriptions = self.aggregator.aggregate(
892+ [product_pofile, package_pofile])
893+
894+ expected = [
895+ (product, canonical_url(product_pofile), [product_pofile]),
896+ (package, canonical_url(package_pofile), [package_pofile]),
897+ ]
898+ self.assertEqual(expected, descriptions)
899+
900+ def test_aggregate_bundles_productseries(self):
901+ # _aggregateTranslationTargets describes POFiles for the same
902+ # ProductSeries together.
903+ pofile1 = self.factory.makePOFile(language_code='es')
904+ series = pofile1.potemplate.productseries
905+ template2 = self.factory.makePOTemplate(productseries=series)
906+ pofile2 = self.factory.makePOFile(
907+ language_code='br', potemplate=template2)
908+
909+ pofiles = [pofile1, pofile2]
910+ descriptions = self.aggregator.aggregate(pofiles)
911+
912+ self.assertEqual(1, len(descriptions))
913+ self.assertEqual(
914+ [(series.product, canonical_url(series), pofiles)], descriptions)
915+
916+ def test_aggregate_bundles_package(self):
917+ # _aggregateTranslationTargets describes POFiles for the same
918+ # ProductSeries together.
919+ package = self.factory.makeSourcePackage()
920+ package.distroseries.distribution.official_rosetta = True
921+ template1 = self.factory.makePOTemplate(
922+ distroseries=package.distroseries,
923+ sourcepackagename=package.sourcepackagename)
924+ pofile1 = self.factory.makePOFile(
925+ language_code='es', potemplate=template1)
926+ template2 = self.factory.makePOTemplate(
927+ distroseries=package.distroseries,
928+ sourcepackagename=package.sourcepackagename)
929+ pofile2 = self.factory.makePOFile(
930+ language_code='br', potemplate=template2)
931+
932+ pofiles = [pofile1, pofile2]
933+ descriptions = self.aggregator.aggregate(pofiles)
934+
935+ expected = [(package, canonical_url(package), pofiles)]
936+ self.assertEqual(expected, descriptions)
937+
938+
939+def test_suite():
940+ return TestLoader().loadTestsFromName(__name__)
941
942=== added file 'lib/lp/translations/browser/translationlinksaggregator.py'
943--- lib/lp/translations/browser/translationlinksaggregator.py 1970-01-01 00:00:00 +0000
944+++ lib/lp/translations/browser/translationlinksaggregator.py 2009-08-25 10:26:28 +0000
945@@ -0,0 +1,186 @@
946+# Copyright 2009 Canonical Ltd. This software is licensed under the
947+# GNU Affero General Public License version 3 (see the file LICENSE).
948+
949+__metaclass__ = type
950+
951+__all__ = [
952+ 'TranslationLinksAggregator',
953+ ]
954+
955+from canonical.launchpad.webapp import canonical_url
956+from lp.translations.interfaces.pofile import IPOFile
957+from lp.translations.model.productserieslanguage import ProductSeriesLanguage
958+
959+
960+class TranslationLinksAggregator:
961+ """Aggregate `POFile`s and/or `POTemplate`s into meaningful targets.
962+
963+ Here, `POFile`s and `POTemplate`s are referred to collectively as
964+ "sheets."
965+ """
966+
967+ # Suffix to append to URL when linking to a POFile.
968+ pofile_link_suffix = ''
969+
970+ def describe(self, target, link, covered_sheets):
971+ """Overridable: return description of given translations link.
972+
973+ :param target: `Product` or `SourcePackage`.
974+ :param link: URL linking to `covered_sheets` in the UI.
975+ :param covered_sheets: `POFile`s and/or `POTemplate`s being
976+ linked and described together.
977+ :return: Some description that will get added to a list and
978+ returned by `aggregate`.
979+ """
980+ raise NotImplementedError()
981+
982+ def _bundle(self, sheets):
983+ """Bundle `sheets` based on target: `Product` or `SourcePackage`.
984+
985+ :param sheets: Sequence of `POFile`s and/or `POTemplate`s.
986+ :return: Dict mapping each targets to a list representing its
987+ `POFile`s and `POTemplate`s as found in `sheets`.
988+ """
989+ targets = {}
990+ for sheet in sheets:
991+ if IPOFile.providedBy(sheet):
992+ template = sheet.potemplate
993+ else:
994+ template = sheet
995+
996+ if template.productseries:
997+ target = template.productseries.product
998+ else:
999+ distroseries = template.distroseries
1000+ sourcepackagename = template.sourcepackagename
1001+ target = distroseries.getSourcePackage(sourcepackagename)
1002+
1003+ if target not in targets:
1004+ targets[target] = []
1005+
1006+ targets[target].append(sheet)
1007+
1008+ return targets
1009+
1010+ def _composeLink(self, sheet):
1011+ """Produce a link to a `POFile` or `POTemplate`."""
1012+ link = canonical_url(sheet)
1013+ if IPOFile.providedBy(sheet):
1014+ link += self.pofile_link_suffix
1015+
1016+ return link
1017+
1018+ def _getTemplate(self, sheet):
1019+ """Return `POTemplate` for `sheet`.
1020+
1021+ :param sheet: A `POTemplate` or `POFile`.
1022+ """
1023+ if IPOFile.providedBy(sheet):
1024+ return sheet.potemplate
1025+ else:
1026+ return sheet
1027+
1028+ def _getLanguage(self, sheet):
1029+ """Return language `sheet` is in, if `sheet` is an `IPOFile`."""
1030+ if IPOFile.providedBy(sheet):
1031+ return sheet.language
1032+ else:
1033+ return None
1034+
1035+ def _countLanguages(self, sheets):
1036+ """Count languages among `sheets`.
1037+
1038+ A template's language is None, which also counts.
1039+ """
1040+ return len(set(self._getLanguage(sheet) for sheet in sheets))
1041+
1042+ def _circumscribe(self, sheets):
1043+ """Find the best common UI link to cover all of `sheets`.
1044+
1045+ :param sheets: List of `POFile`s and/or `POTemplate`s.
1046+ :return: Dict containing a set of links and the respective lists
1047+ of `sheets` they cover.
1048+ """
1049+ first_sheet = sheets[0]
1050+ if len(sheets) == 1:
1051+ # Simple case: one sheet.
1052+ return {self._composeLink(first_sheet): sheets}
1053+
1054+ templates = set([self._getTemplate(sheet) for sheet in sheets])
1055+
1056+ productseries = set(
1057+ template.productseries
1058+ for template in templates
1059+ if template.productseries)
1060+
1061+ products = set(series.product for series in productseries)
1062+
1063+ sourcepackagenames = set(
1064+ template.sourcepackagename
1065+ for template in templates
1066+ if template.sourcepackagename)
1067+
1068+ distroseries = set(
1069+ template.distroseries
1070+ for template in templates
1071+ if template.sourcepackagename)
1072+
1073+ assert len(products) <= 1, "Got more than one product."
1074+ assert len(sourcepackagenames) <= 1, "Got more than one package."
1075+ assert len(distroseries) <= 1, "Got more than one distroseries."
1076+ assert len(products) + len(sourcepackagenames) == 1, (
1077+ "Didn't get exactly one product or one package.")
1078+
1079+ first_template = self._getTemplate(first_sheet)
1080+
1081+ if len(templates) == 1:
1082+ # Multiple inputs, but all for the same template. Link to
1083+ # the template.
1084+ return {self._composeLink(first_template): sheets}
1085+
1086+ if sourcepackagenames:
1087+ # Multiple inputs, but they have to be all for the same
1088+ # source package. Show its template listing.
1089+ distroseries = first_template.distroseries
1090+ packagename = first_template.sourcepackagename
1091+ link = canonical_url(distroseries.getSourcePackage(packagename))
1092+ return {link: sheets}
1093+
1094+ if len(productseries) == 1:
1095+ # All for the same ProductSeries.
1096+ series = first_template.productseries
1097+ if self._countLanguages(sheets) == 1:
1098+ # All for the same language in the same ProductSeries,
1099+ # though still for different templates. Link to
1100+ # ProductSeriesLanguage.
1101+ productserieslanguage = ProductSeriesLanguage(
1102+ series, first_sheet.language)
1103+ return {canonical_url(productserieslanguage): sheets}
1104+ else:
1105+ # Multiple templates and languages in the same product
1106+ # series, or a mix of templates and at least one
1107+ # language. Show the product series' templates listing.
1108+ return {canonical_url(series): sheets}
1109+
1110+ # Different release series of the same product. Break down into
1111+ # individual sheets. We could try recursing here to get a better
1112+ # set of aggregated links, but may not be worth the trouble.
1113+ return dict(
1114+ (self._composeLink(sheet), [sheet]) for sheet in sheets)
1115+
1116+ def aggregate(self, sheets):
1117+ """Aggregate `sheets` into a list of translation target descriptions.
1118+
1119+ Targets are aggregated into "sensible" chunks first.c
1120+
1121+ :return: A list of whatever the implementation for `describe`
1122+ returns for the sensible chunks.
1123+ """
1124+ links = []
1125+ for target, sheets in self._bundle(sheets).iteritems():
1126+ assert sheets, "Translation target has no POFiles or templates."
1127+ links_and_sheets = self._circumscribe(sheets)
1128+ for link, covered_sheets in links_and_sheets.iteritems():
1129+ links.append(self.describe(target, link, covered_sheets))
1130+
1131+ return links