Merge lp:~jtv/launchpad/bug-517700 into lp:launchpad
- bug-517700
- Merge into devel
Status: | Merged | ||||||||
---|---|---|---|---|---|---|---|---|---|
Approved by: | Jeroen T. Vermeulen | ||||||||
Approved revision: | no longer in the source branch. | ||||||||
Merged at revision: | 11464 | ||||||||
Proposed branch: | lp:~jtv/launchpad/bug-517700 | ||||||||
Merge into: | lp:launchpad | ||||||||
Diff against target: |
795 lines (+456/-144) 7 files modified
lib/canonical/launchpad/doc/launchpad-views-cookie.txt (+2/-2) lib/lp/translations/browser/pofile.py (+160/-90) lib/lp/translations/browser/tests/test_pofile_view.py (+259/-10) lib/lp/translations/interfaces/translationsperson.py (+3/-0) lib/lp/translations/model/translationsperson.py (+4/-0) lib/lp/translations/templates/pofile-translate.pt (+1/-42) lib/lp/translations/tests/test_translationsperson.py (+27/-0) |
||||||||
To merge this branch: | bzr merge lp:~jtv/launchpad/bug-517700 | ||||||||
Related bugs: |
|
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Abel Deuring (community) | code | Approve | |
Review via email: mp+33888@code.launchpad.net |
Commit message
+translate "bubble help" for new translators
Description of the change
= Bugs 484375, 517700 =
As sketched out by Matthew Revell and others, this adds something to the "help bubble" that we show on translation pages that have documentation worthy of the user's attention.
The part that is added is a link to introductory documentation. This is added on top of the existing links for a translation group's guidelines and a translation team's style guide.
No changes in interaction were needed, apart from the bubble now also being shown if the user is logged in but has never translated. The changes may look bigger than they are because I lifted the bewildering bubble fragment out of the TAL and moved it into the browser code. Easier to read, easier to test, faster. There's also a pagetest, but it passes unmodified (yay!).
In the browser code, I factored out a bunch of properties that were common to two view classes. At first I thought one of these view classes was scheduled to replace the other, justifying some temporary duplication, but according to the docstring it's actually set to replace a _different_ set of view classes. So I eliminated the duplication.
The view test was running in LaunchpadZopeless layer, but had no need for either the Librarian or memcached so I downgraded it to ZopelessDatabas
Jeroen
Preview Diff
1 | === modified file 'lib/canonical/launchpad/doc/launchpad-views-cookie.txt' | |||
2 | --- lib/canonical/launchpad/doc/launchpad-views-cookie.txt 2009-03-06 19:09:45 +0000 | |||
3 | +++ lib/canonical/launchpad/doc/launchpad-views-cookie.txt 2010-08-27 10:57:43 +0000 | |||
4 | @@ -35,7 +35,7 @@ | |||
5 | 35 | >>> launchpad_views['small_maps'] | 35 | >>> launchpad_views['small_maps'] |
6 | 36 | False | 36 | False |
7 | 37 | 37 | ||
9 | 38 | Any other value is treated as True because that is default state. | 38 | Any other value is treated as True because that is the default state. |
10 | 39 | 39 | ||
11 | 40 | >>> launchpad_views = test_get_launchpad_views( | 40 | >>> launchpad_views = test_get_launchpad_views( |
12 | 41 | ... 'launchpad_views=small_maps=true') | 41 | ... 'launchpad_views=small_maps=true') |
13 | @@ -47,7 +47,7 @@ | |||
14 | 47 | >>> launchpad_views['small_maps'] | 47 | >>> launchpad_views['small_maps'] |
15 | 48 | True | 48 | True |
16 | 49 | 49 | ||
18 | 50 | Keys that are note predefined in get_launchpad_views are not accepted. | 50 | Keys that are not predefined in get_launchpad_views are not accepted. |
19 | 51 | 51 | ||
20 | 52 | >>> launchpad_views = test_get_launchpad_views( | 52 | >>> launchpad_views = test_get_launchpad_views( |
21 | 53 | ... 'launchpad_views=bad_key=false') | 53 | ... 'launchpad_views=bad_key=false') |
22 | 54 | 54 | ||
23 | === modified file 'lib/lp/translations/browser/pofile.py' | |||
24 | --- lib/lp/translations/browser/pofile.py 2010-08-20 20:31:18 +0000 | |||
25 | +++ lib/lp/translations/browser/pofile.py 2010-08-27 10:57:43 +0000 | |||
26 | @@ -1,4 +1,4 @@ | |||
28 | 1 | # Copyright 2009 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2009-2010 Canonical Ltd. This software is licensed under the |
29 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
30 | 3 | 3 | ||
31 | 4 | """Browser code for Translation files.""" | 4 | """Browser code for Translation files.""" |
32 | @@ -16,17 +16,18 @@ | |||
33 | 16 | 'POFileView', | 16 | 'POFileView', |
34 | 17 | ] | 17 | ] |
35 | 18 | 18 | ||
36 | 19 | from cgi import escape | ||
37 | 19 | import os.path | 20 | import os.path |
38 | 20 | import re | 21 | import re |
39 | 21 | import urllib | 22 | import urllib |
40 | 22 | 23 | ||
41 | 23 | from zope.app.form.browser import DropdownWidget | ||
42 | 24 | from zope.component import getUtility | 24 | from zope.component import getUtility |
43 | 25 | from zope.publisher.browser import FileUpload | 25 | from zope.publisher.browser import FileUpload |
44 | 26 | 26 | ||
45 | 27 | from canonical.cachedproperty import cachedproperty | 27 | from canonical.cachedproperty import cachedproperty |
46 | 28 | from canonical.config import config | 28 | from canonical.config import config |
47 | 29 | from canonical.launchpad import _ | 29 | from canonical.launchpad import _ |
48 | 30 | from canonical.launchpad.interfaces import ILaunchBag | ||
49 | 30 | from canonical.launchpad.webapp import ( | 31 | from canonical.launchpad.webapp import ( |
50 | 31 | canonical_url, | 32 | canonical_url, |
51 | 32 | enabled_with_permission, | 33 | enabled_with_permission, |
52 | @@ -59,12 +60,6 @@ | |||
53 | 59 | from lp.translations.interfaces.translationsperson import ITranslationsPerson | 60 | from lp.translations.interfaces.translationsperson import ITranslationsPerson |
54 | 60 | 61 | ||
55 | 61 | 62 | ||
56 | 62 | class CustomDropdownWidget(DropdownWidget): | ||
57 | 63 | def _div(self, cssClass, contents, **kw): | ||
58 | 64 | """Render the select widget without the div tag.""" | ||
59 | 65 | return contents | ||
60 | 66 | |||
61 | 67 | |||
62 | 68 | class POFileNavigation(Navigation): | 63 | class POFileNavigation(Navigation): |
63 | 69 | 64 | ||
64 | 70 | usedfor = IPOFile | 65 | usedfor = IPOFile |
65 | @@ -143,7 +138,149 @@ | |||
66 | 143 | links = ('details', 'translate', 'upload', 'download') | 138 | links = ('details', 'translate', 'upload', 'download') |
67 | 144 | 139 | ||
68 | 145 | 140 | ||
70 | 146 | class POFileBaseView(LaunchpadView): | 141 | class POFileMetadataViewMixin: |
71 | 142 | """`POFile` metadata that multiple views can use.""" | ||
72 | 143 | |||
73 | 144 | @cachedproperty | ||
74 | 145 | def translation_group(self): | ||
75 | 146 | """Is there a translation group for this translation? | ||
76 | 147 | |||
77 | 148 | :return: TranslationGroup or None if not found. | ||
78 | 149 | """ | ||
79 | 150 | translation_groups = self.context.potemplate.translationgroups | ||
80 | 151 | if translation_groups is not None and len(translation_groups) > 0: | ||
81 | 152 | group = translation_groups[0] | ||
82 | 153 | else: | ||
83 | 154 | group = None | ||
84 | 155 | return group | ||
85 | 156 | |||
86 | 157 | @cachedproperty | ||
87 | 158 | def translator_entry(self): | ||
88 | 159 | """The translator entry or None if none is assigned.""" | ||
89 | 160 | group = self.translation_group | ||
90 | 161 | if group is not None: | ||
91 | 162 | return group.query_translator(self.context.language) | ||
92 | 163 | return None | ||
93 | 164 | |||
94 | 165 | @cachedproperty | ||
95 | 166 | def translator(self): | ||
96 | 167 | """Who is assigned for translations to this language?""" | ||
97 | 168 | translator_entry = self.translator_entry | ||
98 | 169 | if translator_entry is not None: | ||
99 | 170 | return translator_entry.translator | ||
100 | 171 | return None | ||
101 | 172 | |||
102 | 173 | @cachedproperty | ||
103 | 174 | def user_is_new_translator(self): | ||
104 | 175 | """Is this user someone who has done no translation work yet?""" | ||
105 | 176 | user = getUtility(ILaunchBag).user | ||
106 | 177 | if user is not None: | ||
107 | 178 | translationsperson = ITranslationsPerson(user) | ||
108 | 179 | if not translationsperson.hasTranslated(): | ||
109 | 180 | return True | ||
110 | 181 | |||
111 | 182 | return False | ||
112 | 183 | |||
113 | 184 | @cachedproperty | ||
114 | 185 | def translation_group_guide(self): | ||
115 | 186 | """URL to translation group's translation guide, if any.""" | ||
116 | 187 | group = self.translation_group | ||
117 | 188 | if group is None: | ||
118 | 189 | return None | ||
119 | 190 | else: | ||
120 | 191 | return group.translation_guide_url | ||
121 | 192 | |||
122 | 193 | @cachedproperty | ||
123 | 194 | def translation_team_guide(self): | ||
124 | 195 | """URL to translation team's translation guide, if any.""" | ||
125 | 196 | translator = self.translator_entry | ||
126 | 197 | if translator is None: | ||
127 | 198 | return None | ||
128 | 199 | else: | ||
129 | 200 | return translator.style_guide_url | ||
130 | 201 | |||
131 | 202 | @cachedproperty | ||
132 | 203 | def has_any_documentation(self): | ||
133 | 204 | """Return whether there is any documentation for this POFile.""" | ||
134 | 205 | return ( | ||
135 | 206 | self.translation_group_guide is not None or | ||
136 | 207 | self.translation_team_guide is not None or | ||
137 | 208 | self.user_is_new_translator) | ||
138 | 209 | |||
139 | 210 | @property | ||
140 | 211 | def introduction_link(self): | ||
141 | 212 | """Link to introductory documentation, if appropriate. | ||
142 | 213 | |||
143 | 214 | If no link is appropriate, returns the empty string. | ||
144 | 215 | """ | ||
145 | 216 | if not self.user_is_new_translator: | ||
146 | 217 | return "" | ||
147 | 218 | |||
148 | 219 | return """ | ||
149 | 220 | New to translating in Launchpad? | ||
150 | 221 | <a href="/+help/new-to-translating.html" target="help"> | ||
151 | 222 | Read our guide</a>. | ||
152 | 223 | """ | ||
153 | 224 | |||
154 | 225 | @property | ||
155 | 226 | def guide_links(self): | ||
156 | 227 | """Links to translation group/team guidelines, if available. | ||
157 | 228 | |||
158 | 229 | If no guidelines are available, returns the empty string. | ||
159 | 230 | """ | ||
160 | 231 | group_guide = self.translation_group_guide | ||
161 | 232 | team_guide = self.translation_team_guide | ||
162 | 233 | if group_guide is None and team_guide is None: | ||
163 | 234 | return "" | ||
164 | 235 | |||
165 | 236 | links = [] | ||
166 | 237 | if group_guide is not None: | ||
167 | 238 | links.append(""" | ||
168 | 239 | <a class="style-guide-url" href="%s">%s instructions</a> | ||
169 | 240 | """ % (group_guide, escape(self.translation_group.title))) | ||
170 | 241 | |||
171 | 242 | if team_guide is not None: | ||
172 | 243 | if group_guide is None: | ||
173 | 244 | # Use team's full name. | ||
174 | 245 | name = self.translator.displayname | ||
175 | 246 | else: | ||
176 | 247 | # Full team name may get tedious after we just named the | ||
177 | 248 | # group. Just use the language name. | ||
178 | 249 | name = self.context.language.englishname | ||
179 | 250 | links.append(""" | ||
180 | 251 | <a class="style-guide-url" href="%s"> %s guidelines</a> | ||
181 | 252 | """ % (team_guide, escape(name))) | ||
182 | 253 | |||
183 | 254 | text = ' and '.join(links).rstrip() | ||
184 | 255 | |||
185 | 256 | return "Before translating, be sure to go through %s." % text | ||
186 | 257 | |||
187 | 258 | @property | ||
188 | 259 | def documentation_link_bubble(self): | ||
189 | 260 | """Reference to documentation, if appopriate.""" | ||
190 | 261 | if not self.has_any_documentation: | ||
191 | 262 | return "" | ||
192 | 263 | |||
193 | 264 | return """ | ||
194 | 265 | <div class="important-notice-container"> | ||
195 | 266 | <div class="important-notice-balloon"> | ||
196 | 267 | <div class="important-notice-buttons"> | ||
197 | 268 | <img class="important-notice-cancel-button" | ||
198 | 269 | src="/@@/no" | ||
199 | 270 | alt="Don't show this notice anymore" | ||
200 | 271 | title="Hide this notice." /> | ||
201 | 272 | </div> | ||
202 | 273 | <span class="sprite info"> | ||
203 | 274 | <span class="important-notice"> | ||
204 | 275 | %s | ||
205 | 276 | </span> | ||
206 | 277 | </div> | ||
207 | 278 | </div> | ||
208 | 279 | """ % ' '.join([ | ||
209 | 280 | self.introduction_link, self.guide_links]) | ||
210 | 281 | |||
211 | 282 | |||
212 | 283 | class POFileBaseView(LaunchpadView, POFileMetadataViewMixin): | ||
213 | 147 | """A basic view for a POFile | 284 | """A basic view for a POFile |
214 | 148 | 285 | ||
215 | 149 | This view is different from POFileView as it is the base for a new | 286 | This view is different from POFileView as it is the base for a new |
216 | @@ -161,7 +298,6 @@ | |||
217 | 161 | 298 | ||
218 | 162 | self.batchnav = self._buildBatchNavigator() | 299 | self.batchnav = self._buildBatchNavigator() |
219 | 163 | 300 | ||
220 | 164 | |||
221 | 165 | @cachedproperty | 301 | @cachedproperty |
222 | 166 | def contributors(self): | 302 | def contributors(self): |
223 | 167 | return tuple(self.context.contributors) | 303 | return tuple(self.context.contributors) |
224 | @@ -250,46 +386,6 @@ | |||
225 | 250 | return self.context.language.pluralexpression | 386 | return self.context.language.pluralexpression |
226 | 251 | return "" | 387 | return "" |
227 | 252 | 388 | ||
228 | 253 | @cachedproperty | ||
229 | 254 | def translation_group(self): | ||
230 | 255 | """Is there a translation group for this translation? | ||
231 | 256 | |||
232 | 257 | :return: TranslationGroup or None if not found. | ||
233 | 258 | """ | ||
234 | 259 | translation_groups = self.context.potemplate.translationgroups | ||
235 | 260 | if translation_groups is not None and len(translation_groups) > 0: | ||
236 | 261 | group = translation_groups[0] | ||
237 | 262 | else: | ||
238 | 263 | group = None | ||
239 | 264 | return group | ||
240 | 265 | |||
241 | 266 | def _get_translator_entry(self): | ||
242 | 267 | """The translator entry or None if none is assigned.""" | ||
243 | 268 | group = self.translation_group | ||
244 | 269 | if group is not None: | ||
245 | 270 | return group.query_translator(self.context.language) | ||
246 | 271 | return None | ||
247 | 272 | |||
248 | 273 | @cachedproperty | ||
249 | 274 | def translator(self): | ||
250 | 275 | """Who is assigned for translations to this language?""" | ||
251 | 276 | translator_entry = self._get_translator_entry() | ||
252 | 277 | if translator_entry is not None: | ||
253 | 278 | return translator_entry.translator | ||
254 | 279 | return None | ||
255 | 280 | |||
256 | 281 | @cachedproperty | ||
257 | 282 | def has_any_documentation(self): | ||
258 | 283 | """Return whether there is any documentation for this POFile.""" | ||
259 | 284 | if (self.translation_group is not None and | ||
260 | 285 | self.translation_group.translation_guide_url is not None): | ||
261 | 286 | return True | ||
262 | 287 | translator_entry = self._get_translator_entry() | ||
263 | 288 | if (translator_entry is not None and | ||
264 | 289 | translator_entry.style_guide_url is not None): | ||
265 | 290 | return True | ||
266 | 291 | return False | ||
267 | 292 | |||
268 | 293 | def _initializeShowOption(self): | 389 | def _initializeShowOption(self): |
269 | 294 | # Get any value given by the user | 390 | # Get any value given by the user |
270 | 295 | self.show = self.request.form_ng.getOne('show') | 391 | self.show = self.request.form_ng.getOne('show') |
271 | @@ -462,6 +558,12 @@ | |||
272 | 462 | 558 | ||
273 | 463 | 559 | ||
274 | 464 | class TranslationMessageContainer: | 560 | class TranslationMessageContainer: |
275 | 561 | """A `TranslationMessage` decorated with usage class. | ||
276 | 562 | |||
277 | 563 | The usage class (in-use, hidden" or suggested) is used in CSS to | ||
278 | 564 | render these messages differently. | ||
279 | 565 | """ | ||
280 | 566 | |||
281 | 465 | def __init__(self, translation, pofile): | 567 | def __init__(self, translation, pofile): |
282 | 466 | self.data = translation | 568 | self.data = translation |
283 | 467 | 569 | ||
284 | @@ -478,6 +580,8 @@ | |||
285 | 478 | 580 | ||
286 | 479 | 581 | ||
287 | 480 | class FilteredPOTMsgSets: | 582 | class FilteredPOTMsgSets: |
288 | 583 | """`POTMsgSet`s and translations shown by the `POFileFilteredView`.""" | ||
289 | 584 | |||
290 | 481 | def __init__(self, translations, pofile): | 585 | def __init__(self, translations, pofile): |
291 | 482 | potmsgsets = [] | 586 | potmsgsets = [] |
292 | 483 | current_potmsgset = None | 587 | current_potmsgset = None |
293 | @@ -494,10 +598,10 @@ | |||
294 | 494 | potmsgsets.append(current_potmsgset) | 598 | potmsgsets.append(current_potmsgset) |
295 | 495 | translation.setPOFile(pofile) | 599 | translation.setPOFile(pofile) |
296 | 496 | current_potmsgset = { | 600 | current_potmsgset = { |
301 | 497 | 'potmsgset' : translation.potmsgset, | 601 | 'potmsgset': translation.potmsgset, |
302 | 498 | 'translations' : [TranslationMessageContainer( | 602 | 'translations': [ |
303 | 499 | translation, pofile)], | 603 | TranslationMessageContainer(translation, pofile)], |
304 | 500 | 'context' : translation | 604 | 'context': translation, |
305 | 501 | } | 605 | } |
306 | 502 | if current_potmsgset is not None: | 606 | if current_potmsgset is not None: |
307 | 503 | potmsgsets.append(current_potmsgset) | 607 | potmsgsets.append(current_potmsgset) |
308 | @@ -523,7 +627,7 @@ | |||
309 | 523 | """See `LaunchpadView`.""" | 627 | """See `LaunchpadView`.""" |
310 | 524 | return smartquote('Translations by %s in "%s"') % ( | 628 | return smartquote('Translations by %s in "%s"') % ( |
311 | 525 | self._person_name, self.context.title) | 629 | self._person_name, self.context.title) |
313 | 526 | 630 | ||
314 | 527 | def label(self): | 631 | def label(self): |
315 | 528 | """See `LaunchpadView`.""" | 632 | """See `LaunchpadView`.""" |
316 | 529 | return "Translations by %s" % self._person_name | 633 | return "Translations by %s" % self._person_name |
317 | @@ -663,7 +767,7 @@ | |||
318 | 663 | return config.rosetta.translate_pages_max_batch_size | 767 | return config.rosetta.translate_pages_max_batch_size |
319 | 664 | 768 | ||
320 | 665 | 769 | ||
322 | 666 | class POFileTranslateView(BaseTranslationView): | 770 | class POFileTranslateView(BaseTranslationView, POFileMetadataViewMixin): |
323 | 667 | """The View class for a `POFile` or a `DummyPOFile`. | 771 | """The View class for a `POFile` or a `DummyPOFile`. |
324 | 668 | 772 | ||
325 | 669 | This view is based on `BaseTranslationView` and implements the API | 773 | This view is based on `BaseTranslationView` and implements the API |
326 | @@ -711,40 +815,6 @@ | |||
327 | 711 | # BaseTranslationView API | 815 | # BaseTranslationView API |
328 | 712 | # | 816 | # |
329 | 713 | 817 | ||
330 | 714 | @cachedproperty | ||
331 | 715 | def translation_group(self): | ||
332 | 716 | """Is there a translation group for this translation? | ||
333 | 717 | |||
334 | 718 | :return: TranslationGroup or None if not found. | ||
335 | 719 | """ | ||
336 | 720 | translation_groups = self.context.potemplate.translationgroups | ||
337 | 721 | if translation_groups is not None and len(translation_groups) > 0: | ||
338 | 722 | group = translation_groups[0] | ||
339 | 723 | else: | ||
340 | 724 | group = None | ||
341 | 725 | return group | ||
342 | 726 | |||
343 | 727 | @cachedproperty | ||
344 | 728 | def translation_team(self): | ||
345 | 729 | """Is there a translation group for this translation.""" | ||
346 | 730 | group = self.translation_group | ||
347 | 731 | if group is not None: | ||
348 | 732 | team = group.query_translator(self.context.language) | ||
349 | 733 | else: | ||
350 | 734 | team = None | ||
351 | 735 | return team | ||
352 | 736 | |||
353 | 737 | @cachedproperty | ||
354 | 738 | def has_any_documentation(self): | ||
355 | 739 | """Return whether there is any documentation for this POFile.""" | ||
356 | 740 | if (self.translation_group is not None and | ||
357 | 741 | self.translation_group.translation_guide_url is not None): | ||
358 | 742 | return True | ||
359 | 743 | if (self.translation_team is not None and | ||
360 | 744 | self.translation_team.style_guide_url is not None): | ||
361 | 745 | return True | ||
362 | 746 | return False | ||
363 | 747 | |||
364 | 748 | def _buildBatchNavigator(self): | 818 | def _buildBatchNavigator(self): |
365 | 749 | """See BaseTranslationView._buildBatchNavigator.""" | 819 | """See BaseTranslationView._buildBatchNavigator.""" |
366 | 750 | 820 | ||
367 | 751 | 821 | ||
368 | === modified file 'lib/lp/translations/browser/tests/test_pofile_view.py' | |||
369 | --- lib/lp/translations/browser/tests/test_pofile_view.py 2010-08-20 20:31:18 +0000 | |||
370 | +++ lib/lp/translations/browser/tests/test_pofile_view.py 2010-08-27 10:57:43 +0000 | |||
371 | @@ -1,4 +1,4 @@ | |||
373 | 1 | # Copyright 2009 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2009-2010 Canonical Ltd. This software is licensed under the |
374 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
375 | 3 | 3 | ||
376 | 4 | __metaclass__ = type | 4 | __metaclass__ = type |
377 | @@ -7,14 +7,16 @@ | |||
378 | 7 | datetime, | 7 | datetime, |
379 | 8 | timedelta, | 8 | timedelta, |
380 | 9 | ) | 9 | ) |
381 | 10 | from unittest import TestLoader | ||
382 | 11 | 10 | ||
383 | 12 | import pytz | 11 | import pytz |
384 | 13 | 12 | ||
385 | 14 | from canonical.launchpad.webapp.servers import LaunchpadTestRequest | 13 | from canonical.launchpad.webapp.servers import LaunchpadTestRequest |
387 | 15 | from canonical.testing import LaunchpadZopelessLayer | 14 | from canonical.testing import ZopelessDatabaseLayer |
388 | 16 | from lp.app.errors import UnexpectedFormData | 15 | from lp.app.errors import UnexpectedFormData |
390 | 17 | from lp.testing import TestCaseWithFactory | 16 | from lp.testing import ( |
391 | 17 | login, | ||
392 | 18 | TestCaseWithFactory, | ||
393 | 19 | ) | ||
394 | 18 | from lp.translations.browser.pofile import ( | 20 | from lp.translations.browser.pofile import ( |
395 | 19 | POFileBaseView, | 21 | POFileBaseView, |
396 | 20 | POFileTranslateView, | 22 | POFileTranslateView, |
397 | @@ -24,7 +26,7 @@ | |||
398 | 24 | class TestPOFileBaseViewFiltering(TestCaseWithFactory): | 26 | class TestPOFileBaseViewFiltering(TestCaseWithFactory): |
399 | 25 | """Test POFileBaseView filtering functions.""" | 27 | """Test POFileBaseView filtering functions.""" |
400 | 26 | 28 | ||
402 | 27 | layer = LaunchpadZopelessLayer | 29 | layer = ZopelessDatabaseLayer |
403 | 28 | 30 | ||
404 | 29 | def gen_now(self): | 31 | def gen_now(self): |
405 | 30 | now = datetime.now(pytz.UTC) | 32 | now = datetime.now(pytz.UTC) |
406 | @@ -161,7 +163,7 @@ | |||
407 | 161 | class TestPOFileBaseViewInvalidFiltering(TestCaseWithFactory, | 163 | class TestPOFileBaseViewInvalidFiltering(TestCaseWithFactory, |
408 | 162 | TestInvalidFilteringMixin): | 164 | TestInvalidFilteringMixin): |
409 | 163 | """Test for POFilleBaseView.""" | 165 | """Test for POFilleBaseView.""" |
411 | 164 | layer = LaunchpadZopelessLayer | 166 | layer = ZopelessDatabaseLayer |
412 | 165 | view_class = POFileBaseView | 167 | view_class = POFileBaseView |
413 | 166 | 168 | ||
414 | 167 | def setUp(self): | 169 | def setUp(self): |
415 | @@ -172,7 +174,7 @@ | |||
416 | 172 | class TestPOFileTranslateViewInvalidFiltering(TestCaseWithFactory, | 174 | class TestPOFileTranslateViewInvalidFiltering(TestCaseWithFactory, |
417 | 173 | TestInvalidFilteringMixin): | 175 | TestInvalidFilteringMixin): |
418 | 174 | """Test for POFilleTranslateView.""" | 176 | """Test for POFilleTranslateView.""" |
420 | 175 | layer = LaunchpadZopelessLayer | 177 | layer = ZopelessDatabaseLayer |
421 | 176 | view_class = POFileTranslateView | 178 | view_class = POFileTranslateView |
422 | 177 | 179 | ||
423 | 178 | def setUp(self): | 180 | def setUp(self): |
424 | @@ -180,6 +182,253 @@ | |||
425 | 180 | self.pofile = self.factory.makePOFile('eo') | 182 | self.pofile = self.factory.makePOFile('eo') |
426 | 181 | 183 | ||
427 | 182 | 184 | ||
431 | 183 | def test_suite(): | 185 | class DocumentationScenarioMixin: |
432 | 184 | return TestLoader().loadTestsFromName(__name__) | 186 | """Tests for `POFileBaseView` and `POFileTranslateView`.""" |
433 | 185 | 187 | # The view class that's being tested. | |
434 | 188 | view_class = None | ||
435 | 189 | |||
436 | 190 | def _makeLoggedInUser(self): | ||
437 | 191 | """Create a user, and log in as that user.""" | ||
438 | 192 | email = self.factory.getUniqueString() + '@example.com' | ||
439 | 193 | user = self.factory.makePerson(email=email) | ||
440 | 194 | login(email) | ||
441 | 195 | return user | ||
442 | 196 | |||
443 | 197 | def _useNonnewTranslator(self): | ||
444 | 198 | """Create a user who's done translations, and log in as that user.""" | ||
445 | 199 | user = self._makeLoggedInUser() | ||
446 | 200 | self.factory.makeSharedTranslationMessage( | ||
447 | 201 | translator=user, suggestion=True) | ||
448 | 202 | return user | ||
449 | 203 | |||
450 | 204 | def _makeView(self, pofile=None, request=None): | ||
451 | 205 | """Create a view of type `view_class`. | ||
452 | 206 | |||
453 | 207 | :param pofile: An optional `POFile`. If not given, one will be | ||
454 | 208 | created. | ||
455 | 209 | :param request: An optional `LaunchpadTestRequest`. If not | ||
456 | 210 | given, one will be created. | ||
457 | 211 | """ | ||
458 | 212 | if pofile is None: | ||
459 | 213 | pofile = self.factory.makePOFile('cy') | ||
460 | 214 | if request is None: | ||
461 | 215 | request = LaunchpadTestRequest() | ||
462 | 216 | return self.view_class(pofile, request) | ||
463 | 217 | |||
464 | 218 | def _makeTranslationGroup(self, pofile): | ||
465 | 219 | """Set up a translation group for pofile if it doesn't have one.""" | ||
466 | 220 | product = pofile.potemplate.productseries.product | ||
467 | 221 | if product.translationgroup is None: | ||
468 | 222 | product.translationgroup = self.factory.makeTranslationGroup() | ||
469 | 223 | return product.translationgroup | ||
470 | 224 | |||
471 | 225 | def _makeTranslationTeam(self, pofile): | ||
472 | 226 | """Create a translation team applying to pofile.""" | ||
473 | 227 | language = pofile.language.code | ||
474 | 228 | group = self._makeTranslationGroup(pofile) | ||
475 | 229 | return self.factory.makeTranslator(language, group=group) | ||
476 | 230 | |||
477 | 231 | def _setGroupGuide(self, pofile): | ||
478 | 232 | """Set the translation group guide URL for pofile.""" | ||
479 | 233 | guide = "http://%s.example.com/" % self.factory.getUniqueString() | ||
480 | 234 | self._makeTranslationGroup(pofile).translation_guide_url = guide | ||
481 | 235 | return guide | ||
482 | 236 | |||
483 | 237 | def _setTeamGuide(self, pofile, team=None): | ||
484 | 238 | """Set the translation team style guide URL for pofile.""" | ||
485 | 239 | guide = "http://%s.example.com/" % self.factory.getUniqueString() | ||
486 | 240 | if team is None: | ||
487 | 241 | team = self._makeTranslationTeam(pofile) | ||
488 | 242 | team.style_guide_url = guide | ||
489 | 243 | return guide | ||
490 | 244 | |||
491 | 245 | def _showsIntro(self, bubble_text): | ||
492 | 246 | """Does bubble_text show the intro for new translators?""" | ||
493 | 247 | return "New to translating in Launchpad?" in bubble_text | ||
494 | 248 | |||
495 | 249 | def _showsGuides(self, bubble_text): | ||
496 | 250 | """Does bubble_text show translation group/team guidelines?""" | ||
497 | 251 | return "Before translating" in bubble_text | ||
498 | 252 | |||
499 | 253 | def test_user_is_new_translator_anonymous(self): | ||
500 | 254 | # An anonymous user is not a new translator. | ||
501 | 255 | self.assertFalse(self._makeView().user_is_new_translator) | ||
502 | 256 | |||
503 | 257 | def test_user_is_new_translator_new(self): | ||
504 | 258 | # A user who's never done any translations is a new translator. | ||
505 | 259 | self._makeLoggedInUser() | ||
506 | 260 | self.assertTrue(self._makeView().user_is_new_translator) | ||
507 | 261 | |||
508 | 262 | def test_user_is_new_translator_not_new(self): | ||
509 | 263 | # A user who has done translations is not a new translator. | ||
510 | 264 | self._useNonnewTranslator() | ||
511 | 265 | self.assertFalse(self._makeView().user_is_new_translator) | ||
512 | 266 | |||
513 | 267 | def test_translation_group_guide_nogroup(self): | ||
514 | 268 | # If there's no translation group, there is no | ||
515 | 269 | # translation_group_guide. | ||
516 | 270 | self.assertIs(None, self._makeView().translation_group_guide) | ||
517 | 271 | |||
518 | 272 | def test_translation_group_guide_noguide(self): | ||
519 | 273 | # The translation group may not have a translation guide. | ||
520 | 274 | pofile = self.factory.makePOFile('ca') | ||
521 | 275 | self._makeTranslationGroup(pofile) | ||
522 | 276 | |||
523 | 277 | view = self._makeView(pofile=pofile) | ||
524 | 278 | self.assertIs(None, view.translation_group_guide) | ||
525 | 279 | |||
526 | 280 | def test_translation_group_guide(self): | ||
527 | 281 | # translation_group_guide returns the translation group's style | ||
528 | 282 | # guide URL if there is one. | ||
529 | 283 | pofile = self.factory.makePOFile('ce') | ||
530 | 284 | url = self._setGroupGuide(pofile) | ||
531 | 285 | |||
532 | 286 | view = self._makeView(pofile=pofile) | ||
533 | 287 | self.assertEqual(url, view.translation_group_guide) | ||
534 | 288 | |||
535 | 289 | def test_translation_team_guide_nogroup(self): | ||
536 | 290 | # If there is no translation group, there is no translation team | ||
537 | 291 | # style guide. | ||
538 | 292 | self.assertIs(None, self._makeView().translation_team_guide) | ||
539 | 293 | |||
540 | 294 | def test_translation_team_guide_noteam(self): | ||
541 | 295 | # If there is no translation team for this language, there is on | ||
542 | 296 | # translation team style guide. | ||
543 | 297 | pofile = self.factory.makePOFile('ch') | ||
544 | 298 | self._makeTranslationGroup(pofile) | ||
545 | 299 | |||
546 | 300 | view = self._makeView(pofile=pofile) | ||
547 | 301 | self.assertIs(None, view.translation_team_guide) | ||
548 | 302 | |||
549 | 303 | def test_translation_team_guide_noguide(self): | ||
550 | 304 | # A translation team may not have a translation style guide. | ||
551 | 305 | pofile = self.factory.makePOFile('co') | ||
552 | 306 | self._makeTranslationTeam(pofile) | ||
553 | 307 | |||
554 | 308 | view = self._makeView(pofile=pofile) | ||
555 | 309 | self.assertIs(None, view.translation_team_guide) | ||
556 | 310 | |||
557 | 311 | def test_translation_team_guide(self): | ||
558 | 312 | # translation_team_guide returns the translation team's | ||
559 | 313 | # style guide, if there is one. | ||
560 | 314 | pofile = self.factory.makePOFile('cy') | ||
561 | 315 | url = self._setTeamGuide(pofile) | ||
562 | 316 | |||
563 | 317 | view = self._makeView(pofile=pofile) | ||
564 | 318 | self.assertEqual(url, view.translation_team_guide) | ||
565 | 319 | |||
566 | 320 | def test_documentation_link_bubble_empty(self): | ||
567 | 321 | # If the user is not a new translator and neither a translation | ||
568 | 322 | # group nor a team style guide applies, the documentation bubble | ||
569 | 323 | # is empty. | ||
570 | 324 | pofile = self.factory.makePOFile('da') | ||
571 | 325 | self._useNonnewTranslator() | ||
572 | 326 | |||
573 | 327 | view = self._makeView(pofile=pofile) | ||
574 | 328 | self.assertEqual('', view.documentation_link_bubble) | ||
575 | 329 | self.assertFalse(self._showsIntro(view.documentation_link_bubble)) | ||
576 | 330 | self.assertFalse(self._showsGuides(view.documentation_link_bubble)) | ||
577 | 331 | |||
578 | 332 | def test_documentation_link_bubble_intro(self): | ||
579 | 333 | # New users are shown an intro link. | ||
580 | 334 | self._makeLoggedInUser() | ||
581 | 335 | |||
582 | 336 | view = self._makeView() | ||
583 | 337 | self.assertTrue(self._showsIntro(view.documentation_link_bubble)) | ||
584 | 338 | self.assertFalse(self._showsGuides(view.documentation_link_bubble)) | ||
585 | 339 | |||
586 | 340 | def test_documentation_link_bubble_group_guide(self): | ||
587 | 341 | # A translation group's guide shows up in the documentation | ||
588 | 342 | # bubble. | ||
589 | 343 | pofile = self.factory.makePOFile('de') | ||
590 | 344 | self._setGroupGuide(pofile) | ||
591 | 345 | |||
592 | 346 | view = self._makeView(pofile=pofile) | ||
593 | 347 | self.assertFalse(self._showsIntro(view.documentation_link_bubble)) | ||
594 | 348 | self.assertTrue(self._showsGuides(view.documentation_link_bubble)) | ||
595 | 349 | |||
596 | 350 | def test_documentation_link_bubble_team_guide(self): | ||
597 | 351 | # A translation team's style guide shows up in the documentation | ||
598 | 352 | # bubble. | ||
599 | 353 | pofile = self.factory.makePOFile('de') | ||
600 | 354 | self._setTeamGuide(pofile) | ||
601 | 355 | |||
602 | 356 | view = self._makeView(pofile=pofile) | ||
603 | 357 | self.assertFalse(self._showsIntro(view.documentation_link_bubble)) | ||
604 | 358 | self.assertTrue(self._showsGuides(view.documentation_link_bubble)) | ||
605 | 359 | |||
606 | 360 | def test_documentation_link_bubble_both_guides(self): | ||
607 | 361 | # The documentation bubble can show both a translation group's | ||
608 | 362 | # guidelines and a translation team's style guide. | ||
609 | 363 | pofile = self.factory.makePOFile('dv') | ||
610 | 364 | self._setGroupGuide(pofile) | ||
611 | 365 | self._setTeamGuide(pofile) | ||
612 | 366 | |||
613 | 367 | view = self._makeView(pofile=pofile) | ||
614 | 368 | self.assertFalse(self._showsIntro(view.documentation_link_bubble)) | ||
615 | 369 | self.assertTrue(self._showsGuides(view.documentation_link_bubble)) | ||
616 | 370 | self.assertIn(" and ", view.documentation_link_bubble) | ||
617 | 371 | |||
618 | 372 | def test_documentation_link_bubble_shows_all(self): | ||
619 | 373 | # So in all, the bubble can show 3 different documentation | ||
620 | 374 | # links. | ||
621 | 375 | pofile = self.factory.makePOFile('dz') | ||
622 | 376 | self._makeLoggedInUser() | ||
623 | 377 | self._setGroupGuide(pofile) | ||
624 | 378 | self._setTeamGuide(pofile) | ||
625 | 379 | |||
626 | 380 | view = self._makeView(pofile=pofile) | ||
627 | 381 | self.assertTrue(self._showsIntro(view.documentation_link_bubble)) | ||
628 | 382 | self.assertTrue(self._showsGuides(view.documentation_link_bubble)) | ||
629 | 383 | self.assertIn(" and ", view.documentation_link_bubble) | ||
630 | 384 | |||
631 | 385 | def test_documentation_link_bubble_escapes_group_title(self): | ||
632 | 386 | # Translation group titles in the bubble are HTML-escaped. | ||
633 | 387 | pofile = self.factory.makePOFile('eo') | ||
634 | 388 | group = self._makeTranslationGroup(pofile) | ||
635 | 389 | self._setGroupGuide(pofile) | ||
636 | 390 | group.title = "<blink>X</blink>" | ||
637 | 391 | |||
638 | 392 | view = self._makeView(pofile=pofile) | ||
639 | 393 | self.assertIn( | ||
640 | 394 | "<blink>X</blink>", view.documentation_link_bubble) | ||
641 | 395 | self.assertNotIn(group.title, view.documentation_link_bubble) | ||
642 | 396 | |||
643 | 397 | def test_documentation_link_bubble_escapes_team_name(self): | ||
644 | 398 | # Translation team names in the bubble are HTML-escaped. | ||
645 | 399 | pofile = self.factory.makePOFile('ie') | ||
646 | 400 | translator_entry = self._makeTranslationTeam(pofile) | ||
647 | 401 | self._setTeamGuide(pofile, team=translator_entry) | ||
648 | 402 | translator_entry.translator.displayname = "<blink>Y</blink>" | ||
649 | 403 | |||
650 | 404 | view = self._makeView(pofile=pofile) | ||
651 | 405 | self.assertIn( | ||
652 | 406 | "<blink>Y</blink>", view.documentation_link_bubble) | ||
653 | 407 | self.assertNotIn( | ||
654 | 408 | translator_entry.translator.displayname, | ||
655 | 409 | view.documentation_link_bubble) | ||
656 | 410 | |||
657 | 411 | def test_documentation_link_bubble_escapes_language_name(self): | ||
658 | 412 | # Language names in the bubble are HTML-escaped. | ||
659 | 413 | language = self.factory.makeLanguage( | ||
660 | 414 | language_code='wtf', name="<blink>Z</blink>") | ||
661 | 415 | pofile = self.factory.makePOFile('wtf') | ||
662 | 416 | self._setGroupGuide(pofile) | ||
663 | 417 | self._setTeamGuide(pofile) | ||
664 | 418 | |||
665 | 419 | view = self._makeView(pofile=pofile) | ||
666 | 420 | self.assertIn( | ||
667 | 421 | "<blink>Z</blink>", view.documentation_link_bubble) | ||
668 | 422 | self.assertNotIn(language.englishname, view.documentation_link_bubble) | ||
669 | 423 | |||
670 | 424 | |||
671 | 425 | class TestPOFileBaseViewDocumentation(TestCaseWithFactory, | ||
672 | 426 | DocumentationScenarioMixin): | ||
673 | 427 | layer = ZopelessDatabaseLayer | ||
674 | 428 | view_class = POFileBaseView | ||
675 | 429 | |||
676 | 430 | |||
677 | 431 | class TestPOFileTranslateViewDocumentation(TestCaseWithFactory, | ||
678 | 432 | DocumentationScenarioMixin): | ||
679 | 433 | layer = ZopelessDatabaseLayer | ||
680 | 434 | view_class = POFileTranslateView | ||
681 | 186 | 435 | ||
682 | === modified file 'lib/lp/translations/interfaces/translationsperson.py' | |||
683 | --- lib/lp/translations/interfaces/translationsperson.py 2010-08-20 20:31:18 +0000 | |||
684 | +++ lib/lp/translations/interfaces/translationsperson.py 2010-08-27 10:57:43 +0000 | |||
685 | @@ -45,6 +45,9 @@ | |||
686 | 45 | :return: a Storm query result. | 45 | :return: a Storm query result. |
687 | 46 | """ | 46 | """ |
688 | 47 | 47 | ||
689 | 48 | def hasTranslated(): | ||
690 | 49 | """Has this user done any translation work?""" | ||
691 | 50 | |||
692 | 48 | def getReviewableTranslationFiles(no_older_than=None): | 51 | def getReviewableTranslationFiles(no_older_than=None): |
693 | 49 | """List `POFile`s this person should be able to review. | 52 | """List `POFile`s this person should be able to review. |
694 | 50 | 53 | ||
695 | 51 | 54 | ||
696 | === modified file 'lib/lp/translations/model/translationsperson.py' | |||
697 | --- lib/lp/translations/model/translationsperson.py 2010-08-20 20:31:18 +0000 | |||
698 | +++ lib/lp/translations/model/translationsperson.py 2010-08-27 10:57:43 +0000 | |||
699 | @@ -77,6 +77,10 @@ | |||
700 | 77 | entries = Store.of(self.person).find(POFileTranslator, conditions) | 77 | entries = Store.of(self.person).find(POFileTranslator, conditions) |
701 | 78 | return entries.order_by(Desc(POFileTranslator.date_last_touched)) | 78 | return entries.order_by(Desc(POFileTranslator.date_last_touched)) |
702 | 79 | 79 | ||
703 | 80 | def hasTranslated(self): | ||
704 | 81 | """See `ITranslationsPerson`.""" | ||
705 | 82 | return self.getTranslationHistory().any() is not None | ||
706 | 83 | |||
707 | 80 | @property | 84 | @property |
708 | 81 | def translation_history(self): | 85 | def translation_history(self): |
709 | 82 | """See `ITranslationsPerson`.""" | 86 | """See `ITranslationsPerson`.""" |
710 | 83 | 87 | ||
711 | === modified file 'lib/lp/translations/templates/pofile-translate.pt' | |||
712 | --- lib/lp/translations/templates/pofile-translate.pt 2010-05-18 18:04:00 +0000 | |||
713 | +++ lib/lp/translations/templates/pofile-translate.pt 2010-08-27 10:57:43 +0000 | |||
714 | @@ -36,48 +36,7 @@ | |||
715 | 36 | </script> | 36 | </script> |
716 | 37 | 37 | ||
717 | 38 | <!-- Documentation links --> | 38 | <!-- Documentation links --> |
760 | 39 | <tal:documentation condition="view/translation_group"> | 39 | <tal:documentation replace="structure view/documentation_link_bubble" /> |
719 | 40 | <div class="important-notice-container" | ||
720 | 41 | tal:condition="view/has_any_documentation"> | ||
721 | 42 | <div class="important-notice-balloon"> | ||
722 | 43 | <div class="important-notice-buttons"> | ||
723 | 44 | <img class="important-notice-cancel-button" src="/@@/no" | ||
724 | 45 | alt="Don't show this notice anymore" | ||
725 | 46 | title="Hide this notice for the duration of this session" /> | ||
726 | 47 | </div> | ||
727 | 48 | <img src="/@@/info" alt="Information" /> | ||
728 | 49 | <span class="important-notice" | ||
729 | 50 | tal:condition="view/translation_group/translation_guide_url"> | ||
730 | 51 | Before translating, be sure to go through | ||
731 | 52 | <a tal:content="string:${view/translation_group/title} | ||
732 | 53 | instructions" | ||
733 | 54 | tal:attributes="href | ||
734 | 55 | view/translation_group/translation_guide_url"> | ||
735 | 56 | translation instructions</a><!-- | ||
736 | 57 | --><tal:has_team | ||
737 | 58 | condition="view/translation_team"><!-- | ||
738 | 59 | --><tal:has_guidelines | ||
739 | 60 | tal:condition="view/translation_team/style_guide_url"> | ||
740 | 61 | and <a class="style-guide-url" | ||
741 | 62 | tal:attributes=" | ||
742 | 63 | href view/translation_team/style_guide_url" | ||
743 | 64 | tal:content="string:${context/language/englishname} | ||
744 | 65 | guidelines">Serbian guidelines</a><!-- | ||
745 | 66 | --></tal:has_guidelines><!-- | ||
746 | 67 | --></tal:has_team>. | ||
747 | 68 | </span> | ||
748 | 69 | <span class="important-notice" | ||
749 | 70 | tal:condition="not:view/translation_group/translation_guide_url"> | ||
750 | 71 | Before translating, be sure to go through | ||
751 | 72 | <a class="style-guide-url" | ||
752 | 73 | tal:content="string:${view/translation_team/translator/displayname} | ||
753 | 74 | guidelines" | ||
754 | 75 | tal:attributes="href view/translation_team/style_guide_url"> | ||
755 | 76 | Serbian guidelines</a>. | ||
756 | 77 | </span> | ||
757 | 78 | </div> | ||
758 | 79 | </div> | ||
759 | 80 | </tal:documentation> | ||
761 | 81 | 40 | ||
762 | 82 | <tal:havepluralforms condition="view/has_plural_form_information"> | 41 | <tal:havepluralforms condition="view/has_plural_form_information"> |
763 | 83 | 42 | ||
764 | 84 | 43 | ||
765 | === added file 'lib/lp/translations/tests/test_translationsperson.py' | |||
766 | --- lib/lp/translations/tests/test_translationsperson.py 1970-01-01 00:00:00 +0000 | |||
767 | +++ lib/lp/translations/tests/test_translationsperson.py 2010-08-27 10:57:43 +0000 | |||
768 | @@ -0,0 +1,27 @@ | |||
769 | 1 | # Copyright 2010 Canonical Ltd. This software is licensed under the | ||
770 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
771 | 3 | |||
772 | 4 | """Unit tests for TranslationsPerson.""" | ||
773 | 5 | |||
774 | 6 | __metaclass__ = type | ||
775 | 7 | |||
776 | 8 | from canonical.launchpad.webapp.testing import verifyObject | ||
777 | 9 | from canonical.testing import DatabaseFunctionalLayer | ||
778 | 10 | from lp.testing import TestCaseWithFactory | ||
779 | 11 | from lp.translations.interfaces.translationsperson import ITranslationsPerson | ||
780 | 12 | |||
781 | 13 | |||
782 | 14 | class TestTranslationsPerson(TestCaseWithFactory): | ||
783 | 15 | layer = DatabaseFunctionalLayer | ||
784 | 16 | |||
785 | 17 | def test_baseline(self): | ||
786 | 18 | person = ITranslationsPerson(self.factory.makePerson()) | ||
787 | 19 | self.assertTrue(verifyObject(ITranslationsPerson, person)) | ||
788 | 20 | |||
789 | 21 | def test_hasTranslated(self): | ||
790 | 22 | person = self.factory.makePerson() | ||
791 | 23 | translationsperson = ITranslationsPerson(person) | ||
792 | 24 | self.assertFalse(translationsperson.hasTranslated()) | ||
793 | 25 | self.factory.makeTranslationMessage( | ||
794 | 26 | translator=person, suggestion=True) | ||
795 | 27 | self.assertTrue(translationsperson.hasTranslated()) |
nice work!