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

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

= Bug 416434: Stuff to Translate =

This branch is over the review limit, for which I apologize.

We're turning the Person page for Translations into a proper personal
translations dashboard. We've already added a listing of translations
the user could review; this branch adds a listing of ones that the user
could help complete. We spec'ed this out during the Buenos Aires UI
sprint and prototyped it at the Barcelona coding sprint.

== Implementation details ==

As one minor cleanup, PersonTranslationView.history_horizon, the cutoff
date beyond which a person's translation contributions are no longer
considered for these listings, is now initialized when the view is. It
used to be initialized lazily by _setHistoryHorizon, but this just
turned into too many call sites to be sane.

In a previous branch, the view started deciding that a user was a
translator by looking at translations karma. This turned out to be a
bitch to test properly because of restrictive database permissions. So
instead of that it now checks for POFileTranslator entries, using the
same "history horizon" cutoff date. It's cleaner this way, really.

You may notice that I've had to abbreviate property names. In the unit
tests for PersonTranslationsView, I had to resort to p_n_p_to_review and
p_n_p_to_translate for projects_and_packages_to_review and
projects_and_packages_to_translate, respectively.

The Storm code for all this gets pretty complex. It was necessary to
implement things like POFile.untranslatedCount or translations access
levels in database code, when we normally compute those only in Python.
I've reused that code between stuff-to-translate and stuff-to-review
where possible.

Two Storm bugs were discovered while developing this; one, problems with
negative slicing/indexing of result sets, turned out to be already known
with the Storm developers. The code has workarounds for both.

== Tests ==
{{{
./bin/test -vv -t persontranslationview -t translationsperson \
    -t translations-to-complete
}}}

== Demo and Q/A ==

If you have done any translation recently, go to your personal page in
the Translations tab. It will list translations that you could help
translate. The list comprises three sections that by design are not
distinguished. At the top, up to to three will be taken from the ones
that you have worked on that have large numbers of untranslated strings.
After that, up to three will be shown that you have worked on and that
have very few untranslated strings. And finally the list is rounded out
to a maximum of 10 entries by translations that you haven't worked on,
or at least not recently.

You will not see these lists when you look at other people's pages.

== Lint ==

No lint warnings. (Well there were some unused imports, but I've fixed
them now).

Jeroen

Revision history for this message
Aaron Bentley (abentley) wrote :

-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA1

Jeroen T. Vermeulen wrote:
> Jeroen T. Vermeulen has proposed merging lp:~jtv/launchpad/bug-416434 into lp:launchpad/devel.
>
> Requested reviews:
> Canonical Launchpad Engineering (launchpad)
>
> = Bug 416434: Stuff to Translate =
>
> This branch is over the review limit, for which I apologize.
>
> We're turning the Person page for Translations into a proper personal
> translations dashboard. We've already added a listing of translations
> the user could review; this branch adds a listing of ones that the user
> could help complete. We spec'ed this out during the Buenos Aires UI
> sprint and prototyped it at the Barcelona coding sprint.

 merge approved

As we discussed on IRC:

Please stop assigning history_horizon = None as a class variable and
then conditionally re-initializing it as an instance variable in the
constructor. Just initialize it in the constructor unconditionally.

Please change the description of _queryTranslatableFiles's worked_on
parameter to:
:param worked_on: If True, get `POFile`s that the person has been
working on recently (where "recently" is defined as `no_older_than`).
If False, get ones that the person has not been working on recently
(those that the person has never worked on, or last worked on before
`no_older_than`)

Otherwise, this is good to land.
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v1.4.9 (GNU/Linux)
Comment: Using GnuPG with Mozilla - http://enigmail.mozdev.org

iEYEARECAAYFAkqcEsYACgkQ0F+nu1YWqI1D/wCeKOWHPy8DGQLJR8mdQxyu/6F3
+PMAoIGPGrY+VMhzYZuf2KsPmyV2WV58
=HjAd
-----END PGP SIGNATURE-----

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/translations/browser/configure.zcml'
2--- lib/lp/translations/browser/configure.zcml 2009-08-28 22:27:49 +0000
3+++ lib/lp/translations/browser/configure.zcml 2009-08-31 06:58:44 +0000
4@@ -737,6 +737,13 @@
5 permission="zope.Public"
6 template="../templates/person-translations-to-review-table.pt"
7 layer="canonical.launchpad.layers.TranslationsLayer"/>
8+ <browser:page
9+ for="lp.registry.interfaces.person.IPerson"
10+ name="+translations-to-complete-table"
11+ class="lp.translations.browser.person.PersonTranslationView"
12+ permission="zope.Public"
13+ template="../templates/person-translations-to-complete-table.pt"
14+ layer="canonical.launchpad.layers.TranslationsLayer"/>
15
16
17 <!-- Product views -->
18
19=== modified file 'lib/lp/translations/browser/person.py'
20--- lib/lp/translations/browser/person.py 2009-08-26 09:34:50 +0000
21+++ lib/lp/translations/browser/person.py 2009-08-31 14:11:15 +0000
22@@ -69,7 +69,6 @@
23
24 class ReviewLinksAggregator(WorkListLinksAggregator):
25 """A `TranslationLinksAggregator` for translations to review."""
26-
27 # Link to unreviewed suggestions.
28 pofile_link_suffix = '/+translate?show=new_suggestions'
29
30@@ -79,6 +78,17 @@
31 return pofile.unreviewedCount()
32
33
34+class TranslateLinksAggregator(WorkListLinksAggregator):
35+ """A `TranslationLinksAggregator` for translations to complete."""
36+ # Link to untranslated strings.
37+ pofile_link_suffix = '/+translate?show=untranslated'
38+
39+ # Strings that need work are untranslated ones.
40+ def countStrings(self, pofile):
41+ """See `WorkListLinksAggregator.countStrings`."""
42+ return pofile.untranslatedCount()
43+
44+
45 def person_is_reviewer(person):
46 """Is `person` a translations reviewer?"""
47 groups = ITranslationsPerson(person).translation_groups
48@@ -118,6 +128,12 @@
49
50 history_horizon = None
51
52+ def __init__(self, *args, **kwargs):
53+ super(PersonTranslationView, self).__init__(*args, **kwargs)
54+ if self.history_horizon is None:
55+ now = datetime.now(pytz.timezone('UTC'))
56+ self.history_horizon = now - timedelta(90, 0, 0)
57+
58 @cachedproperty
59 def batchnav(self):
60 translations_person = ITranslationsPerson(self.context)
61@@ -156,7 +172,9 @@
62 @property
63 def person_is_translator(self):
64 """Is this person active in translations?"""
65- return self.context.hasKarma('translations')
66+ person = ITranslationsPerson(self.context)
67+ history = person.getTranslationHistory(self.history_horizon).any()
68+ return history is not None
69
70 @property
71 def person_includes_me(self):
72@@ -180,12 +198,6 @@
73 return not (
74 translationmessage.potmsgset.hide_translations_from_anonymous)
75
76- def _setHistoryHorizon(self):
77- """If not already set, set `self.history_horizon`."""
78- if self.history_horizon is None:
79- now = datetime.now(pytz.timezone('UTC'))
80- self.history_horizon = now - timedelta(90, 0, 0)
81-
82 def _getTargetsForReview(self, max_fetch=None):
83 """Query and aggregate the top targets for review.
84
85@@ -195,7 +207,6 @@
86 Multiple `POFile`s may be aggregated together into a single
87 target.
88 """
89- self._setHistoryHorizon()
90 person = ITranslationsPerson(self.context)
91 pofiles = person.getReviewableTranslationFiles(
92 no_older_than=self.history_horizon)
93@@ -214,23 +225,77 @@
94 Multiple `POFile`s may be aggregated together into a single
95 target.
96 """
97- self._setHistoryHorizon()
98 person = ITranslationsPerson(self.context)
99 pofiles = person.suggestReviewableTranslationFiles(
100 no_older_than=self.history_horizon)[:max_fetch]
101
102 return ReviewLinksAggregator().aggregate(pofiles)
103
104+ def _getTargetsForTranslation(self, max_fetch=None):
105+ """Get translation targets for this person to translate.
106+
107+ Results are ordered from most to fewest untranslated messages.
108+ """
109+ person = ITranslationsPerson(self.context)
110+ urgent_first = (max_fetch >= 0)
111+ pofiles = person.getTranslatableFiles(
112+ no_older_than=self.history_horizon, urgent_first=urgent_first)
113+
114+ if max_fetch is not None:
115+ pofiles = pofiles[:abs(max_fetch)]
116+
117+ return TranslateLinksAggregator().aggregate(pofiles)
118+
119+ def _suggestTargetsForTranslation(self, max_fetch=None):
120+ """Suggest translations this person could be helping complete."""
121+ person = ITranslationsPerson(self.context)
122+ pofiles = person.suggestTranslatableFiles(
123+ no_older_than=self.history_horizon)
124+
125+ return TranslateLinksAggregator().aggregate(pofiles)
126+
127 @cachedproperty
128 def all_projects_and_packages_to_review(self):
129 """Top projects and packages for this person to review."""
130 return self._getTargetsForReview()
131
132+ def _addToTargetsList(self, existing_targets, new_targets, max_items,
133+ max_overall):
134+ """Add `new_targets` to `existing_targets` list.
135+
136+ This is for use in showing top-10 ists of translations a user
137+ should help review or complete.
138+
139+ :param existing_targets: Translation targets that are already
140+ being listed.
141+ :param new_targets: Translation targets to add. Ones that were
142+ already in `existing_targets` will not be added again.
143+ :param max_items: Maximum number of targets from `new_targets`
144+ to add.
145+ :param max_overall: Maximum overall size of the resulting list.
146+ What happens if `existing_targets` already exceeds this size
147+ is none of your business.
148+ :return: A list of translation targets containing all of
149+ `existing_targets`, followed by as many from `new_targets`
150+ as there is room for.
151+ """
152+ remaining_slots = max_overall - len(existing_targets)
153+ maximum_addition = min(max_items, remaining_slots)
154+ if remaining_slots <= 0:
155+ return existing_targets
156+
157+ known_targets = set([item['target'] for item in existing_targets])
158+ really_new = [
159+ item
160+ for item in new_targets
161+ if item['target'] not in known_targets
162+ ]
163+
164+ return existing_targets + really_new[:maximum_addition]
165+
166 @property
167 def top_projects_and_packages_to_review(self):
168 """Suggest translations for this person to review."""
169- self._setHistoryHorizon()
170-
171 # Maximum number of projects/packages to list that this person
172 # has recently worked on.
173 max_known_targets = 9
174@@ -241,22 +306,54 @@
175 # worked on. Aggregation may reduce the number we get, so ask
176 # the database for a few extra.
177 fetch = 5 * max_known_targets
178- recent = self._getTargetsForReview(fetch)[:max_known_targets]
179-
180- # Fill out the list with other translations that the person
181- # could also be reviewing.
182- empty_slots = list_length - len(recent)
183- fetch = 5 * empty_slots
184- random_suggestions = self._suggestTargetsForReview(fetch)
185- random_suggestions = random_suggestions[:empty_slots]
186-
187- return recent + random_suggestions
188+ recent = self._getTargetsForReview(fetch)
189+ overall = self._addToTargetsList(
190+ [], recent, max_known_targets, list_length)
191+
192+ # Fill out the list with other, randomly suggested translations
193+ # that the person could also be reviewing.
194+ fetch = 5 * (list_length - len(overall))
195+ suggestions = self._suggestTargetsForReview(fetch)
196+ overall = self._addToTargetsList(
197+ overall, suggestions, list_length, list_length)
198+
199+ return overall
200
201 @cachedproperty
202 def num_projects_and_packages_to_review(self):
203 """How many translations do we suggest for reviewing?"""
204 return len(self.all_projects_and_packages_to_review)
205
206+ @property
207+ def top_projects_and_packages_to_translate(self):
208+ """Suggest translations for this person to help complete."""
209+ # Maximum number of translations to list that need the most work
210+ # done.
211+ max_urgent_targets = 3
212+ # Maximum number of translations to list that are almost
213+ # complete.
214+ max_almost_complete_targets = 3
215+ # Length of overall list to display.
216+ list_length = 10
217+
218+ fetch = 5 * max_urgent_targets
219+ urgent = self._getTargetsForTranslation(fetch)
220+ overall = self._addToTargetsList(
221+ [], urgent, max_urgent_targets, list_length)
222+
223+ fetch = 5 * max_almost_complete_targets
224+ almost_complete = self._getTargetsForTranslation(-fetch)
225+ overall = self._addToTargetsList(
226+ overall, almost_complete, max_almost_complete_targets,
227+ list_length)
228+
229+ fetch = 5 * (list_length - len(overall))
230+ suggestions = self._suggestTargetsForTranslation(fetch)
231+ overall = self._addToTargetsList(
232+ overall, suggestions, list_length, list_length)
233+
234+ return overall
235+
236
237 class PersonTranslationRelicensingView(LaunchpadFormView):
238 """View for Person's translation relicensing page."""
239
240=== modified file 'lib/lp/translations/browser/tests/test_persontranslationview.py'
241--- lib/lp/translations/browser/tests/test_persontranslationview.py 2009-08-25 10:26:28 +0000
242+++ lib/lp/translations/browser/tests/test_persontranslationview.py 2009-08-31 14:51:57 +0000
243@@ -26,14 +26,16 @@
244 super(TestPersonTranslationView, self).setUp()
245 person = removeSecurityProxy(self.factory.makePerson())
246 self.view = PersonTranslationView(person, LaunchpadTestRequest())
247+ self.translationgroup = None
248+ self.dutch = LanguageSet().getLanguageByCode('nl')
249+ self.view.context.addLanguage(self.dutch)
250
251 def _makeReviewer(self):
252 """Set up the person we're looking at as a Dutch reviewer."""
253 owner = self.factory.makePerson()
254 self.translationgroup = self.factory.makeTranslationGroup(owner=owner)
255- dutch = LanguageSet().getLanguageByCode('nl')
256 TranslatorSet().new(
257- translationgroup=self.translationgroup, language=dutch,
258+ translationgroup=self.translationgroup, language=self.dutch,
259 translator=self.view.context)
260
261 def _makePOFiles(self, count, previously_worked_on):
262@@ -46,8 +48,10 @@
263 pofiles = []
264 for counter in xrange(count):
265 pofile = self.factory.makePOFile(language_code='nl')
266- product = pofile.potemplate.productseries.product
267- product.translationgroup = self.translationgroup
268+
269+ if self.translationgroup:
270+ product = pofile.potemplate.productseries.product
271+ product.translationgroup = self.translationgroup
272
273 if previously_worked_on:
274 potmsgset = self.factory.makePOTMsgSet(
275@@ -61,6 +65,11 @@
276
277 return pofiles
278
279+ def _addUntranslatedMessages(self, pofile, untranslated_messages):
280+ """Add to `pofile`'s count of untranslated messages."""
281+ template = pofile.potemplate
282+ removeSecurityProxy(template).messagecount += untranslated_messages
283+
284 def test_translation_groups(self):
285 # translation_groups lists the translation groups a person is
286 # in.
287@@ -157,7 +166,7 @@
288 self.assertEqual(
289 set(expected_links), set(item['link'] for item in targets))
290
291- def test_top_projects_and_packages_caps_existing_involvement(self):
292+ def test_top_p_n_p_to_review_caps_existing_involvement(self):
293 # top_projects_and_packages will return at most 9 POFiles that
294 # the person has already worked on.
295 self._makeReviewer()
296@@ -168,7 +177,7 @@
297 self.assertEqual(9, len(targets))
298 self.assertEqual(9, len(set(item['link'] for item in targets)))
299
300- def test_top_projects_and_packages_caps_suggestions(self):
301+ def test_top_p_n_p_to_review_caps_suggestions(self):
302 # top_projects_and_packages will suggest at most 10 POFiles that
303 # the person has not worked on.
304 self._makeReviewer()
305@@ -179,7 +188,7 @@
306 self.assertEqual(10, len(targets))
307 self.assertEqual(10, len(set(item['link'] for item in targets)))
308
309- def test_top_projects_and_packages_caps_total(self):
310+ def test_top_p_n_p_to_review_caps_total(self):
311 # top_projects_and_packages will show at most 10 POFiles
312 # overall. The last one will be a suggestion.
313 self._makeReviewer()
314@@ -192,6 +201,141 @@
315 self.assertEqual(10, len(targets))
316 self.assertEqual(10, len(set(item['link'] for item in targets)))
317
318+ def test_person_is_translator_false(self):
319+ # By default, a user is not a translator.
320+ self.assertFalse(self.view.person_is_translator)
321+
322+ def test_person_is_translator_true(self):
323+ # Doing translation work turns a user into a translator.
324+ self._makePOFiles(1, previously_worked_on=True)
325+
326+ self.assertTrue(self.view.person_is_translator)
327+
328+ def test_getTargetsForTranslation(self):
329+ # If there's nothing to translate, _getTargetsForTranslation
330+ # returns nothing.
331+ self.assertEqual([], self.view._getTargetsForTranslation())
332+
333+ # If there's a translation that this person has worked on and
334+ # is not a reviewer for, and it has untranslated strings, it
335+ # shows up in _getTargetsForTranslation.
336+ pofile = self._makePOFiles(1, previously_worked_on=True)[0]
337+ self._addUntranslatedMessages(pofile, 1)
338+ product = pofile.potemplate.productseries.product
339+
340+ descriptions = self.view._getTargetsForTranslation()
341+
342+ self.assertEqual(1, len(descriptions))
343+ description = descriptions[0]
344+ self.assertEqual(product, description['target'])
345+ self.assertTrue(description['link'].startswith(canonical_url(pofile)))
346+
347+ def test_getTargetsForTranslation_max_fetch(self):
348+ # The max_fetch parameter limits how many POFiles are considered
349+ # by _getTargetsForTranslation. This lets you get the target(s)
350+ # with the most untranslated messages.
351+ pofiles = self._makePOFiles(3, previously_worked_on=True)
352+ urgent_pofile = pofiles[2]
353+ medium_pofile = pofiles[1]
354+ nonurgent_pofile = pofiles[0]
355+ self._addUntranslatedMessages(urgent_pofile, 10)
356+ self._addUntranslatedMessages(medium_pofile, 2)
357+ self._addUntranslatedMessages(nonurgent_pofile, 1)
358+
359+ descriptions = self.view._getTargetsForTranslation(1)
360+ self.assertEqual(1, len(descriptions))
361+ self.assertEqual(
362+ urgent_pofile.potemplate.productseries.product,
363+ descriptions[0]['target'])
364+
365+ # Passing a negative max_fetch makes _getTargetsForTranslation
366+ # pick translations with the fewest untranslated messages.
367+ descriptions = self.view._getTargetsForTranslation(-1)
368+ self.assertEqual(1, len(descriptions))
369+ self.assertEqual(
370+ nonurgent_pofile.potemplate.productseries.product,
371+ descriptions[0]['target'])
372+
373+ def test_suggestTargetsForTranslation(self):
374+ previous_contrib = self._makePOFiles(1, previously_worked_on=True)
375+ pofile = self._makePOFiles(1, previously_worked_on=False)[0]
376+ self._addUntranslatedMessages(pofile, 1)
377+
378+ descriptions = self.view._suggestTargetsForTranslation()
379+
380+ self.assertEqual(1, len(descriptions))
381+ self.assertEqual(
382+ pofile.potemplate.productseries.product,
383+ descriptions[0]['target'])
384+
385+ def test_top_projects_and_packages_to_translate(self):
386+ # top_projects_and_packages_to_translate lists targets that the
387+ # user has worked on and could help translate, followed by
388+ # randomly suggested ones that also need translation.
389+ worked_on = self._makePOFiles(1, previously_worked_on=True)[0]
390+ self._addUntranslatedMessages(worked_on, 1)
391+ not_worked_on = self._makePOFiles(1, previously_worked_on=False)[0]
392+ self._addUntranslatedMessages(not_worked_on, 1)
393+
394+ descriptions = self.view.top_projects_and_packages_to_translate
395+
396+ self.assertEqual(2, len(descriptions))
397+ self.assertEqual(
398+ worked_on.potemplate.productseries.product,
399+ descriptions[0]['target'])
400+ self.assertEqual(
401+ not_worked_on.potemplate.productseries.product,
402+ descriptions[1]['target'])
403+
404+ def test_top_p_n_p_to_translate_caps_existing_involvement(self):
405+ # top_projects_and_packages_to_translate shows no more than 6
406+ # targets that the user has already worked on.
407+ pofiles = self._makePOFiles(7, previously_worked_on=True)
408+ for pofile in pofiles:
409+ self._addUntranslatedMessages(pofile, 1)
410+
411+ descriptions = self.view.top_projects_and_packages_to_translate
412+
413+ self.assertEqual(6, len(descriptions))
414+
415+ def test_top_p_n_p_to_translate_lists_most_and_least_translated(self):
416+ # Of the maximum of 6 translations that the user has already
417+ # worked on, the first 3 will be the ones with the most
418+ # untranslated strings; the last 3 will have the fewest.
419+ # We create a lot more POFiles because internally the property
420+ # will fetch many POFiles.
421+ pofiles = self._makePOFiles(50, previously_worked_on=True)
422+ for number, pofile in enumerate(pofiles):
423+ self._addUntranslatedMessages(pofile, number + 1)
424+ products = [
425+ pofile.potemplate.productseries.product for pofile in pofiles]
426+
427+ descriptions = self.view.top_projects_and_packages_to_translate
428+
429+ self.assertEqual(6, len(descriptions))
430+ targets = [item['target'] for item in descriptions]
431+
432+ # We happen to know that no more than 15 POFiles are fetched for
433+ # each of the two categories, so the top 3 targets must be taken
434+ # from the last 15 pofiles and the next 3 must be taken from the
435+ # first 15 pofiles.
436+ self.assertTrue(set(targets[:3]).issubset(products[15:]))
437+ self.assertTrue(set(targets[3:]).issubset(products[:15]))
438+
439+ # No target is mentioned more than once in the listing.
440+ self.assertEqual(len(targets), len(set(targets)))
441+
442+ def test_top_p_n_p_to_translate_caps_total(self):
443+ # The list never shows more than 10 entries.
444+ for previously_worked_on in (True, False):
445+ pofiles = self._makePOFiles(
446+ 11, previously_worked_on=previously_worked_on)
447+ for pofile in pofiles:
448+ self._addUntranslatedMessages(pofile, 1)
449+
450+ descriptions = self.view.top_projects_and_packages_to_translate
451+ self.assertEqual(10, len(descriptions))
452+
453
454 def test_suite():
455 return TestLoader().loadTestsFromName(__name__)
456
457=== modified file 'lib/lp/translations/doc/translationsperson.txt'
458--- lib/lp/translations/doc/translationsperson.txt 2009-07-10 11:46:50 +0000
459+++ lib/lp/translations/doc/translationsperson.txt 2009-08-29 06:37:30 +0000
460@@ -1,6 +1,6 @@
461 = TranslationsPerson =
462
463-Adapting IPerson to an ITranslationsPerson yields a TranslationPerson
464+Adapting IPerson to an ITranslationsPerson yields a TranslationsPerson
465 object which provides translatable languages and translation history.
466
467 >>> from zope.component import getUtility
468
469=== modified file 'lib/lp/translations/interfaces/translationsperson.py'
470--- lib/lp/translations/interfaces/translationsperson.py 2009-08-19 14:25:32 +0000
471+++ lib/lp/translations/interfaces/translationsperson.py 2009-08-31 14:11:15 +0000
472@@ -34,6 +34,14 @@
473 title=_("Whether person agrees to relicense their translations"),
474 readonly=False)
475
476+ def getTranslationHistory(no_older_than=None):
477+ """Query most recent `POFileTranslator` entries for this person.
478+
479+ :param no_older_than: Optional cutoff date. If given, older
480+ `POFileTranslator` entries are ignored.
481+ :return: a Storm query result.
482+ """
483+
484 def getReviewableTranslationFiles(no_older_than=None):
485 """List `POFile`s this person should be able to review.
486
487@@ -43,7 +51,7 @@
488 :param no_older_than: Optional cutoff date. Translations that
489 this person hasn't contributed to since this date will be
490 ignored.
491- :return: a sequence of `POFile`s ordered by age of oldest
492+ :return: A query result of `POFile`s ordered by age of oldest
493 unreviewed `TranslationMessage` (oldest first).
494 """
495
496@@ -56,3 +64,26 @@
497
498 :param maximum: Maximum number of `POFile`s to return.
499 """
500+
501+ def getTranslatableFiles(no_older_than=None, urgent_first=True):
502+ """List `POFile`s this person should be able to help translate.
503+
504+ These are translations that this person is not a reviewer for,
505+ but has worked on recently.
506+
507+ :param no_older_than: Optional cutoff date to define "recently."
508+ :param urgent_first: If True, sort `POFile`s with the most
509+ untranslated strings to the front. If False, sort the other
510+ way around.
511+ :return: A query result of `POFile`s ordered by number of
512+ untranslated messages.
513+ """
514+
515+ def suggestTranslatableFiles(no_older_than=None):
516+ """Suggest `POFile`s this person could help translate.
517+
518+ Similar to `getTranslatableFiles`, this method picks an
519+ arbitrary series of `POFile`s that the user is not a reviewer
520+ for and has not worked on recently, but which are in a language
521+ the user works in.
522+ """
523
524=== modified file 'lib/lp/translations/model/translationsperson.py'
525--- lib/lp/translations/model/translationsperson.py 2009-08-19 14:25:32 +0000
526+++ lib/lp/translations/model/translationsperson.py 2009-08-31 14:11:15 +0000
527@@ -6,7 +6,7 @@
528 'TranslationsPerson',
529 ]
530
531-from storm.expr import And, Join, LeftJoin, Or
532+from storm.expr import And, Coalesce, Desc, Join, LeftJoin, Or
533 from storm.info import ClassAlias
534 from storm.store import Store
535
536@@ -19,7 +19,7 @@
537
538 from lp.registry.interfaces.person import IPerson
539 from lp.translations.interfaces.translationgroup import (
540- ITranslationGroupSet)
541+ ITranslationGroupSet, TranslationPermission)
542 from lp.translations.interfaces.translationsperson import (
543 ITranslationsPerson)
544 from lp.translations.interfaces.translator import ITranslatorSet
545@@ -58,12 +58,21 @@
546 Language.visible""" % sqlvalues(self.person),
547 clauseTables=['PersonLanguage'], orderBy='englishname')
548
549+ def getTranslationHistory(self, no_older_than=None):
550+ """See `ITranslationsPerson`."""
551+ conditions = (POFileTranslator.person == self.person)
552+ if no_older_than is not None:
553+ conditions = And(
554+ conditions,
555+ POFileTranslator.date_last_touched >= no_older_than)
556+
557+ entries = Store.of(self.person).find(POFileTranslator, conditions)
558+ return entries.order_by(Desc(POFileTranslator.date_last_touched))
559+
560 @property
561 def translation_history(self):
562 """See `ITranslationsPerson`."""
563- return POFileTranslator.select(
564- 'POFileTranslator.person = %s' % sqlvalues(self.person),
565- orderBy='-date_last_touched')
566+ return self.getTranslationHistory()
567
568 @property
569 def translation_groups(self):
570@@ -143,7 +152,89 @@
571 query = source.find(POFile, conditions)
572 return query.config(distinct=True).order_by(POFile.id)
573
574- def _composePOFileReviewerJoins(self):
575+ def _queryTranslatableFiles(self, worked_on, no_older_than=None,
576+ languages=None):
577+ """Get `POFile`s this person could help translate.
578+
579+ :param worked_on: If True, get `POFile`s that the person has
580+ been working on recently (where "recently" is defined as
581+ `no_older_than`). If False, get ones that the person has
582+ not been working on recently.
583+ :param no_older_than: Oldest involvement to consider as
584+ "recently been working on." Whatever the person did with a
585+ `POFile` before this date is ignored.
586+ :param languages: Optional set of languages to restrict search to.
587+ :return: An unsorted query yielding `POFile`s.
588+ """
589+ if self.person.isTeam():
590+ return []
591+
592+ tables = self._composePOFileReviewerJoins(
593+ expect_reviewer_status=False)
594+
595+ translator_join, translator_condition = (
596+ self._composePOFileTranslatorJoin(worked_on, no_older_than))
597+ tables.append(translator_join)
598+
599+ translated_count = (
600+ POFile.currentcount + POFile.updatescount + POFile.rosettacount)
601+
602+ conditions = And(
603+ translated_count < POTemplate.messagecount, translator_condition)
604+
605+ # The person must not be a reviewer for this translation (unless
606+ # it's in the sense that any user gets review permissions
607+ # for it).
608+ permission = Coalesce(
609+ Distribution.translationpermission,
610+ Product.translationpermission,
611+ Project.translationpermission)
612+ Reviewership = ClassAlias(TeamParticipation, 'Reviewership')
613+ # XXX JeroenVermeulen 2009-08-28 bug=420364: Storm's Coalesce()
614+ # can't currently infer its return type from its inputs, leading
615+ # to a "can't adapt" error. Using the enum's .value works
616+ # around the problem.
617+ not_reviewer = Or(
618+ permission == TranslationPermission.OPEN.value,
619+ And(
620+ permission == TranslationPermission.STRUCTURED.value,
621+ Translator.id == None),
622+ And(
623+ permission == TranslationPermission.RESTRICTED.value,
624+ Translator.id != None,
625+ Reviewership.id == None))
626+
627+ conditions = And(conditions, not_reviewer)
628+
629+ if languages is not None:
630+ conditions = And(conditions, POFile.languageID.is_in(languages))
631+
632+ source = Store.of(self.person).using(*tables)
633+ return source.find(POFile, conditions)
634+
635+ def getTranslatableFiles(self, no_older_than=None, urgent_first=True):
636+ """See `ITranslationsPerson`."""
637+ results = self._queryTranslatableFiles(True, no_older_than)
638+
639+ translated_count = (
640+ POFile.currentcount + POFile.updatescount + POFile.rosettacount)
641+ ordering = translated_count - POTemplate.messagecount
642+ if not urgent_first:
643+ ordering = -ordering
644+
645+ return results.order_by(ordering)
646+
647+ def suggestTranslatableFiles(self, no_older_than=None):
648+ """See `ITranslationsPerson`."""
649+ # XXX JeroenVermeulen 2009-08-28: Ideally this would also check
650+ # for a free license. That's hard to do in SQL though.
651+ languages = set([
652+ language.id for language in self.translatable_languages])
653+ results = self._queryTranslatableFiles(
654+ False, no_older_than, languages=languages)
655+ return results.order_by(['random()'])
656+
657+ def _composePOFileReviewerJoins(self, expect_reviewer_status=True):
658 """Compose certain Storm joins for common `POFile` queries.
659
660 Returns a list of Storm joins for a query on `POFile`. The
661@@ -163,22 +254,21 @@
662 POTemplate.iscurrent == True))
663
664 # This is a weird and complex diamond join. Both DistroSeries
665- # and ProductSeries are left joins, but one of them will
666+ # and ProductSeries are left joins, but one of them may
667 # ultimately lead to a TranslationGroup. In the case of
668 # ProductSeries it may lead to up to two: one for the Product
669 # and one for the Project.
670 DistroSeriesJoin = LeftJoin(
671 DistroSeries, DistroSeries.id == POTemplate.distroseriesID)
672+
673 # If there's a DistroSeries, it should be the distro's
674 # translation focus.
675- # The check for translationgroup here is not necessary, but
676- # should give the query planner some extra selectivity to narrow
677- # down the query more aggressively.
678- DistroJoin = LeftJoin(Distribution, And(
679+ distrojoin_conditions = And(
680 Distribution.id == DistroSeries.distributionID,
681 Distribution.official_rosetta == True,
682- Distribution.translationgroup != None,
683- Distribution.translation_focusID == DistroSeries.id))
684+ Distribution.translation_focusID == DistroSeries.id)
685+
686+ DistroJoin = LeftJoin(Distribution, distrojoin_conditions)
687
688 ProductSeriesJoin = LeftJoin(
689 ProductSeries, ProductSeries.id == POTemplate.productseriesID)
690@@ -188,23 +278,38 @@
691
692 ProjectJoin = LeftJoin(Project, Project.id == Product.projectID)
693
694- # Restrict to translations this person is a reviewer for.
695- GroupJoin = Join(TranslationGroup, Or(
696+ # Look up translation group.
697+ groupjoin_conditions = Or(
698 TranslationGroup.id == Product.translationgroupID,
699 TranslationGroup.id == Distribution.translationgroupID,
700- TranslationGroup.id == Project.translationgroupID))
701- TranslatorJoin = Join(Translator, And(
702+ TranslationGroup.id == Project.translationgroupID)
703+ if expect_reviewer_status:
704+ GroupJoin = Join(TranslationGroup, groupjoin_conditions)
705+ else:
706+ GroupJoin = LeftJoin(TranslationGroup, groupjoin_conditions)
707+
708+ # Look up translation team.
709+ translatorjoin_conditions = And(
710 Translator.translationgroupID == TranslationGroup.id,
711- Translator.languageID == POFile.languageID))
712+ Translator.languageID == POFile.languageID)
713+ if expect_reviewer_status:
714+ TranslatorJoin = Join(Translator, translatorjoin_conditions)
715+ else:
716+ TranslatorJoin = LeftJoin(Translator, translatorjoin_conditions)
717
718 # Check for translation-team membership. Use alias for
719 # TeamParticipation; the query may want to include other
720 # instances of that table. It's just a linking table so the
721 # query won't be interested in its actual contents anyway.
722 Reviewership = ClassAlias(TeamParticipation, 'Reviewership')
723- TranslationTeamJoin = Join(Reviewership, And(
724+ reviewerjoin_condition = And(
725 Reviewership.teamID == Translator.translatorID,
726- Reviewership.personID == self.person.id))
727+ Reviewership.personID == self.person.id)
728+ if expect_reviewer_status:
729+ TranslationTeamJoin = Join(Reviewership, reviewerjoin_condition)
730+ else:
731+ TranslationTeamJoin = LeftJoin(
732+ Reviewership, reviewerjoin_condition)
733
734 return [
735 POFile,
736
737=== added file 'lib/lp/translations/stories/standalone/xx-translations-to-complete.txt'
738--- lib/lp/translations/stories/standalone/xx-translations-to-complete.txt 1970-01-01 00:00:00 +0000
739+++ lib/lp/translations/stories/standalone/xx-translations-to-complete.txt 2009-08-31 13:55:35 +0000
740@@ -0,0 +1,64 @@
741+Translations to Complete
742+------------------------
743+
744+Jean Champollion is a translator.
745+
746+ >>> from zope.security.proxy import removeSecurityProxy
747+ >>> login(ANONYMOUS)
748+
749+ >>> jean = factory.makePerson(
750+ ... name='jean', email='jean@example.com', password='test')
751+
752+Jean has been working on French translations for Foux in Launchpad.
753+
754+ >>> foux = factory.makeProduct(name='foux')
755+ >>> trunk = foux.getSeries('trunk')
756+ >>> template = factory.makePOTemplate(productseries=trunk)
757+ >>> pofile = factory.makePOFile(potemplate=template, language_code='fr')
758+ >>> pofile = removeSecurityProxy(pofile)
759+
760+ >>> potmsgset = factory.makePOTMsgSet(
761+ ... potemplate=template, singular='a', sequence=1)
762+ >>> message = factory.makeTranslationMessage(
763+ ... potmsgset=potmsgset, pofile=pofile, translator=jean,
764+ ... translations=['un'])
765+
766+Foux needs more strings translated.
767+
768+ >>> removeSecurityProxy(pofile.potemplate).messagecount = 2
769+
770+ >>> logout()
771+
772+Jean visits his Translations dashboard.
773+
774+ >>> jean_browser = setupBrowser(auth='Basic jean@example.com:test')
775+ >>> jean_home = 'http://translations.launchpad.dev/~jean'
776+ >>> jean_browser.open(jean_home)
777+
778+The dashboard shows a listing of translations that need Jean's help.
779+
780+ >>> tag = find_tag_by_id(
781+ ... jean_browser.contents, 'translations-to-complete-table')
782+ >>> print tag.renderContents()
783+
784+Only Jean sees his personal listing.
785+
786+ >>> user_browser.open(jean_home)
787+ >>> tag = find_tag_by_id(
788+ ... user_browser.contents, 'translations-to-complete-table')
789+ >>> print tag
790+ None
791+
792+Pierre is not a translator. Pierre does not get such a listing either.
793+
794+ >>> login(ANONYMOUS)
795+ >>> pierre = factory.makePerson(
796+ ... name='pierre', email='pierre@example.com', password='test')
797+ >>> logout()
798+
799+ >>> pierre_browser = setupBrowser(auth='Basic pierre@example.com:test')
800+ >>> pierre_browser.open('http://translations.launchpad.dev/~pierre')
801+ >>> tag = find_tag_by_id(
802+ ... pierre_browser.contents, 'translations-to-complete-table')
803+ >>> print tag
804+ None
805
806=== modified file 'lib/lp/translations/stories/standalone/xx-translations-to-review.txt'
807--- lib/lp/translations/stories/standalone/xx-translations-to-review.txt 2009-08-17 17:08:46 +0000
808+++ lib/lp/translations/stories/standalone/xx-translations-to-review.txt 2009-08-31 13:04:32 +0000
809@@ -1,5 +1,5 @@
810-Stuff to Review
811----------------
812+Translations to Review
813+----------------------
814
815 When a translations reviewer visits their own homepage, it shows a list
816 of translations that they could or should be reviewing.
817
818=== added file 'lib/lp/translations/templates/person-translations-to-complete-table.pt'
819--- lib/lp/translations/templates/person-translations-to-complete-table.pt 1970-01-01 00:00:00 +0000
820+++ lib/lp/translations/templates/person-translations-to-complete-table.pt 2009-08-28 09:44:31 +0000
821@@ -0,0 +1,34 @@
822+<tal:root
823+ xmlns:tal="http://xml.zope.org/namespaces/tal"
824+ xmlns:metal="http://xml.zope.org/namespaces/metal"
825+ xmlns:i18n="http://xml.zope.org/namespaces/i18n"
826+ omit-tag="">
827+
828+<table class="listing" id="translations-to-complete-table">
829+ <tr tal:repeat="target_info view/top_projects_and_packages_to_translate">
830+ <td>
831+ <tal:product condition="target_info/is_product"
832+ replace="structure target_info/target/fmt:link">
833+ alsa-utils
834+ </tal:product>
835+ <tal:package condition="not: target_info/is_product">
836+ <a tal:attributes="href target_info/target/fmt:url">
837+ <img src="/@@/distribution" />
838+ <tal:packagename replace="target_info/target/name">
839+ alsa-utils
840+ </tal:packagename>
841+ </a>
842+ </tal:package>
843+ </td>
844+ <td>
845+ needs
846+ <a tal:attributes="href target_info/link">
847+ <tal:stringcount replace="target_info/count_wording">
848+ 1 string
849+ </tal:stringcount>
850+ translated
851+ </a>
852+ </td>
853+ </tr>
854+</table>
855+</tal:root>
856
857=== modified file 'lib/lp/translations/templates/person-translations.pt'
858--- lib/lp/translations/templates/person-translations.pt 2009-08-19 16:48:17 +0000
859+++ lib/lp/translations/templates/person-translations.pt 2009-08-28 08:23:04 +0000
860@@ -37,6 +37,14 @@
861 </span>
862 </div>
863 </tal:reviewer>
864+
865+ <tal:translator condition="view/person_is_translator">
866+ <div id="translations-to-complete-section" style="max-width:800px">
867+ <h2>Translations you can help complete</h2>
868+ <metal:translations-to-complete
869+ tal:replace="structure context/@@+translations-to-complete-table" />
870+ </div>
871+ </tal:translator>
872 </tal:me>
873
874