Merge lp:~jtv/launchpad/bug-416434 into lp:launchpad
- bug-416434
- Merge into devel
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 | ||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Aaron Bentley (community) | Approve | ||
Review via email:
|
Commit message
Description of the change
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Jeroen T. Vermeulen (jtv) wrote : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
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 _queryTranslata
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://
iEYEARECAAYFAkq
+PMAoIGPGrY+
=HjAd
-----END PGP SIGNATURE-----
Preview Diff
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 |
= 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, PersonTranslati onView. 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 onsView, I had to resort to p_n_p_to_review and and_packages_ to_review and and_packages_ to_translate, respectively.
tests for PersonTranslati
p_n_p_to_translate for projects_
projects_
The Storm code for all this gets pretty complex. It was necessary to untranslatedCou nt or translations access
implement things like POFile.
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 == onview -t translationsperson \ to-complete
{{{
./bin/test -vv -t persontranslati
-t translations-
}}}
== 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