Merge lp:~jtv/launchpad/test-pofile-permissions into lp:launchpad

Proposed by Jeroen T. Vermeulen on 2010-11-04
Status: Merged
Approved by: Gavin Panella on 2010-11-04
Approved revision: no longer in the source branch.
Merged at revision: 11882
Proposed branch: lp:~jtv/launchpad/test-pofile-permissions
Merge into: lp:launchpad
Diff against target: 849 lines (+500/-251)
3 files modified
lib/lp/translations/doc/pofile.txt (+12/-251)
lib/lp/translations/tests/test_pofile.py (+188/-0)
lib/lp/translations/tests/test_translationpermission.py (+300/-0)
To merge this branch: bzr merge lp:~jtv/launchpad/test-pofile-permissions
Reviewer Review Type Date Requested Status
Gavin Panella (community) code Approve on 2010-11-04
Launchpad code reviewers code 2010-11-04 Pending
Review via email: mp+40058@code.launchpad.net

Commit Message

Unit-test translation permissions.

Description of the Change

= Unit Test for POFile Permissions =

When looking at a translation, the privileges model is a bit different from what you see elsewhere in Launchpad. A user can have one of 3 levels of access to any given translation:

1. Nothing. The translation is read-only to this user.
2. Suggest. The user can enter suggestions.
3. Edit. The user can alter existing translations, review suggestions, and set new translations.

Where the user gets their privilege level is a bit of a complicated story. For the ongoing Recife work, we're separating the two ingredients more clearly:
 (i) Personal matters: is the user an admin, have they accepted the licensing agreement, etc?
(ii) Access model: what is allowed by the access model configured for this product or distribution, and its translation group?

Testing for this was a bit haphazard, so messing with the privileges in the Recife branch is risky. That's why I'm first replacing the doctest with two separate unit test cases. Running the same tests against devel and my ongoing Recife work neatly reveals the differences. Mainly, the Recife branch takes away special privileges from POFile owners.

And that is all this branch does. Just testing, no changes. Nevertheless you'll notice a few small irregularities:
 * I no longer test updateTranslations rejecting unpermitted translations, since I cut down the forest that check lived in. However updateTranslations is going away completely in the Recife branch, and for some time now we have been under a blood oath not to make any further changes to the method.
 * I'm not verifying that a product owner who has declined the translations relicensing agreement can enter suggestions. It shouldn't matter, since the editing rights imply this, but in actual fact this is the one situation where pofile.canEditTranslations(user) returns True but pofile.canAddSuggestions(user) returns False.

By the way, the heart of these permissions checks is some of the oldest code in Launchpad, apparently dating back to 2005, and defines some of its most fundamental policy behaviour. It's high time it was unit-tested.

To run the new tests:
{{{
./bin/test -vvc lpl.translations.tests.test_translationpermission
./bin/test -vvc lpl.translations.tests.test_pofile -t Permission
}}}

No lint.

Jeroen

To post a comment you must log in.
Gavin Panella (allenap) wrote :

Right, looks good. That was quite painful to review :)

It was a little mind-bending, but I liked how you managed to keep a
lid on complexity in test_translationpermission.

One miniscule comment/question.

[1]

+ def test_admin_can_edit(self):
+ # Administrators can edit all translations and make suggestions
+ # anywhere.
+ self.closeTranslations()

Why is closeTranslations() needed? Can you explain either here or in
its docstring (assuming my question isn't stupid).

review: Approve
Gavin Panella (allenap) :
review: Approve (code)
Jeroen T. Vermeulen (jtv) wrote :

> Right, looks good. That was quite painful to review :)
>
> It was a little mind-bending, but I liked how you managed to keep a
> lid on complexity in test_translationpermission.
>
> One miniscule comment/question.
>
>
> [1]
>
> + def test_admin_can_edit(self):
> + # Administrators can edit all translations and make suggestions
> + # anywhere.
> + self.closeTranslations()
>
> Why is closeTranslations() needed? Can you explain either here or in
> its docstring (assuming my question isn't stupid).

Good question, actually. It is needed to show that an admin has these rights _despite the chosen access model tending towards keeping people out_. Translations are open by default, and under those circumstances testing that an admin has edit rights has little value.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/translations/doc/pofile.txt'
2--- lib/lp/translations/doc/pofile.txt 2010-11-05 14:56:34 +0000
3+++ lib/lp/translations/doc/pofile.txt 2010-11-08 08:47:44 +0000
4@@ -383,242 +383,6 @@
5 False
6
7
8-canEditTranslations
9--------------------
10-
11-This method determines if someone is allowed to edit translations.
12-
13-Do some needed imports.
14-
15- >>> from canonical.launchpad.interfaces.launchpad import (
16- ... ILaunchpadCelebrities)
17- >>> from lp.registry.interfaces.product import IProductSet
18- >>> from lp.translations.enums import TranslationPermission
19- >>> from lp.translations.interfaces.translationgroup import (
20- ... ITranslationGroupSet)
21- >>> from canonical.launchpad.ftests import login
22- >>> from lp.translations.model.pofile import POFile
23- >>> person_set = getUtility(IPersonSet)
24-
25-Need extra permissions to change the values.
26-
27- >>> login('carlos@canonical.com')
28-
29-Set a translation group to test the CLOSED mode. This mode allows
30-translations only from the teams set as official translators.
31-
32- >>> product = getUtility(IProductSet).getByName('evolution')
33- >>> translation_group_set = getUtility(ITranslationGroupSet)
34- >>> product.translationgroup = translation_group_set[
35- ... 'testing-translation-team']
36- >>> product.translationpermission = TranslationPermission.CLOSED
37-
38-Get the IPOFile we are going to use.
39-
40- >>> product_series = product.translatable_series[0]
41- >>> potemplate = product_series.getPOTemplate('evolution-2.2')
42- >>> pofile_es = potemplate.getPOFileByLang('es')
43-
44-A Launchpad admin must have permission always.
45-
46- >>> admins = getUtility(ILaunchpadCelebrities).admin
47- >>> pofile_es.canEditTranslations(admins)
48- True
49-
50-A Rosetta Expert too.
51-
52- >>> rosetta_experts = getUtility(ILaunchpadCelebrities).rosetta_experts
53- >>> pofile_es.canEditTranslations(rosetta_experts)
54- True
55-
56-And Valentina Commissari, as member of the Spanish translation team for
57-evolution should also have rights.
58-
59- >>> valentina = person_set.getByName('tsukimi')
60- >>> pofile_es.canEditTranslations(valentina)
61- True
62-
63-But the unprivileged account should not.
64-
65- >>> no_priv = person_set.getByName('no-priv')
66- >>> pofile_es.canEditTranslations(no_priv)
67- False
68-
69-And if he tries to update translations, the system blocks such breakage.
70-
71- >>> potmsgset = pofile_es.potemplate.getPOTMsgSetByMsgIDText(
72- ... singular_text=u'evolution addressbook')
73- >>> translation_message = potmsgset.getCurrentTranslationMessage(
74- ... pofile_es.potemplate, pofile_es.language)
75- >>> is_imported = False
76- >>> lock_timestamp = datetime.datetime.now(UTC)
77- >>> translation_message.potmsgset.updateTranslation(
78- ... pofile_es, no_priv, [u'foo'], is_imported, lock_timestamp)
79- Traceback (most recent call last):
80- ...
81- AssertionError: No Privileges Person cannot add suggestions here.
82-
83-Now, we get an IPOFile that does not have a translation team assigned.
84-
85- >>> language_cy = getUtility(ILanguageSet).getLanguageByCode('cy')
86- >>> pofile_cy = potemplate.getDummyPOFile(language_cy)
87-
88-Valentina Commissari is not a translator for this language and does not
89-have permissions.
90-
91- >>> pofile_cy.canEditTranslations(valentina)
92- False
93-
94-And same thing with the unprivileged account.
95-
96- >>> pofile_cy.canEditTranslations(no_priv)
97- False
98-
99-RESTRICTED mode is the same as CLOSED when restricting who is able to
100-change translations.
101-
102- >>> product.translationpermission = TranslationPermission.RESTRICTED
103-
104-A Launchpad admin must have permission always.
105-
106- >>> pofile_es.canEditTranslations(admins)
107- True
108-
109-A Translations Expert too.
110-
111- >>> pofile_es.canEditTranslations(rosetta_experts)
112- True
113-
114-And Valentina Commissari, as member of the Spanish translation team for
115-evolution should also have rights.
116-
117- >>> pofile_es.canEditTranslations(valentina)
118- True
119-
120-But the unprivileged account should not.
121-
122- >>> pofile_es.canEditTranslations(no_priv)
123- False
124-
125-Valentina Commissari still doesn't have permissions to edit translations
126-for Welsh (cy).
127-
128- >>> pofile_cy.canEditTranslations(valentina)
129- False
130-
131-And same thing with the unprivileged account.
132-
133- >>> pofile_cy.canEditTranslations(no_priv)
134- False
135-
136-Now, let's test the STRUCTURED mode. In this mode, only the defined
137-translation teams can translate like the RESTRICTED and CLOSED mode, but
138-in addition, if we don't have any language team for one language, anyone
139-can add translations.
140-
141- >>> product.translationpermission = TranslationPermission.STRUCTURED
142-
143-Valentina Commissari, as member of the Spanish translation team for
144-evolution should have rights for the Spanish IPOFile.
145-
146- >>> pofile_es.canEditTranslations(valentina)
147- True
148-
149-But the unprivileged account should not.
150-
151- >>> pofile_es.canEditTranslations(no_priv)
152- False
153-
154-And this is the difference with the CLOSED mode, anyone will be able to
155-translate into Welsh, as we can see with Valentina:
156-
157- >>> pofile_cy.canEditTranslations(valentina)
158- True
159-
160-And same thing with the unprivileged account.
161-
162- >>> pofile_cy.canEditTranslations(no_priv)
163- True
164-
165-Finally, let's check the OPEN mode to be 100% sure that in that mode
166-anyone can do translations.
167-
168- >>> product.translationgroup = None
169- >>> product.translationpermission = TranslationPermission.OPEN
170-
171-We don't have any translation group for the Evolution product so there
172-are no translators assigned to it, but Valentina Commissari still has
173-rights to do translations.
174-
175- >>> pofile_es.canEditTranslations(valentina)
176- True
177-
178-And samething with the unprivileged account.
179-
180- >>> pofile_es.canEditTranslations(no_priv)
181- True
182-
183-
184-canAddSuggestions
185------------------
186-
187-This method determines if someone is allowed to add suggestions.
188-
189-Set a translation group to test the CLOSED mode. This mode allows
190-translations only from the teams set as official translators.
191-
192- >>> product.translationgroup = translation_group_set[
193- ... 'testing-translation-team']
194- >>> product.translationpermission = TranslationPermission.CLOSED
195-
196-A Launchpad admin must have permission always.
197-
198- >>> pofile_es.canAddSuggestions(admins)
199- True
200-
201-A Translations Expert too.
202-
203- >>> pofile_es.canAddSuggestions(rosetta_experts)
204- True
205-
206-And Valentina Commissari, as member of the Spanish translation team for
207-evolution should also have rights.
208-
209- >>> pofile_es.canAddSuggestions(valentina)
210- True
211-
212-But the unprivileged account should not.
213-
214- >>> pofile_es.canAddSuggestions(no_priv)
215- False
216-
217-RESTRICTED, STRUCTURED and OPEN modes are different from CLOSED mode
218-when handling suggestions because it allows anyone to add suggestions.
219-
220- >>> def canAddSuggestionsCheck(translation_mode):
221- ... product.translationpermission = translation_mode
222- ... assert pofile_es.canAddSuggestions(admins), (
223- ... 'Administrators are not able to add suggestions!')
224- ... assert pofile_es.canAddSuggestions(rosetta_experts), (
225- ... 'Translation experts are not able to add suggestions!')
226- ... assert pofile_es.canAddSuggestions(no_priv), (
227- ... 'A plain user is not able to add suggestions!')
228- ... return True
229-
230- >>> canAddSuggestionsCheck(TranslationPermission.RESTRICTED)
231- True
232-
233- >>> canAddSuggestionsCheck(TranslationPermission.STRUCTURED)
234- True
235-
236- >>> canAddSuggestionsCheck(TranslationPermission.OPEN)
237- True
238-
239- Leave the permission back to OPEN.
240-
241- >>> product.translationpermission = TranslationPermission.OPEN
242-
243-
244 plural_forms
245 ------------
246
247@@ -632,6 +396,7 @@
248 When the language has number of plural forms defined, that value is
249 used.
250
251+ >>> from lp.registry.interfaces.product import IProductSet
252 >>> evolution = getUtility(IProductSet).getByName('evolution')
253 >>> evolution_trunk = evolution.getSeries('trunk')
254 >>> evolution_pot = evolution_trunk.getPOTemplate('evolution-2.2')
255@@ -662,6 +427,7 @@
256
257 Get a concrete POFile we know doesn't have a UTF-8 encoding.
258
259+ >>> from lp.translations.model.pofile import POFile
260 >>> pofile = POFile.get(24)
261 >>> print pofile.header
262 Project-Id-Version: PACKAGE VERSION
263@@ -717,8 +483,9 @@
264
265 So for a concrete export, we have a message like:
266
267+ >>> pofile_es = potemplate.getPOFileByLang('es')
268 >>> print pofile_es.export(force_utf8=True).decode('utf8')
269- # traducci...
270+ # ...
271 ...
272 #: addressbook/gui/widgets/foo.c:345
273 #, c-format
274@@ -748,7 +515,7 @@
275 ...the export reflects that change.
276
277 >>> print pofile_es.export(force_utf8=True).decode('utf8')
278- # traducci...
279+ # ...
280 ...
281 #: addressbook/gui/widgets/foo.c:345
282 #, c-format
283@@ -765,7 +532,7 @@
284
285 >>> untranslated_potmsgset = pofile_es.getPOTMsgSetUntranslated()
286 >>> untranslated_potmsgset.count()
287- 14
288+ 15
289
290 >>> for potmsgset in untranslated_potmsgset:
291 ... pomsgset = potmsgset.getCurrentTranslationMessage(
292@@ -796,8 +563,12 @@
293 ... pofile_sr.potemplate, pofile_sr.language)
294 None
295
296-Is time to create it.
297+Is time to create it. We need some extra privileges here.
298
299+ >>> from canonical.launchpad.interfaces.launchpad import (
300+ ... ILaunchpadCelebrities)
301+ >>> login('carlos@canonical.com')
302+ >>> rosetta_experts = getUtility(ILaunchpadCelebrities).rosetta_experts
303 >>> translations = {0: u''}
304 >>> is_imported = False
305 >>> lock_timestamp = datetime.datetime.now(UTC)
306@@ -816,13 +587,7 @@
307 >>> print translation_message.language.code
308 sr
309
310-This entry is in fact one that is not used anymore, that means, its
311-sequence is zero.
312-
313- >>> translation_message.potmsgset.sequence
314- 0
315-
316-Also, we created it without translations.
317+We created it without translations.
318
319 >>> translation_message.translations
320 [None, None, None]
321@@ -968,10 +733,6 @@
322 >>> noneditor = person_set.getByName('no-priv')
323 >>> editor = person_set.getByName('carlos')
324
325- # Make sure the translation permission mode is restricted
326-
327- >>> product.translationpermission = TranslationPermission.RESTRICTED
328-
329 Non-editor can only submit a new suggestion on untranslated message.
330
331 >>> evolution_es.getPOTMsgSetWithNewSuggestions().count()
332
333=== modified file 'lib/lp/translations/tests/test_pofile.py'
334--- lib/lp/translations/tests/test_pofile.py 2010-10-05 00:08:16 +0000
335+++ lib/lp/translations/tests/test_pofile.py 2010-11-08 08:47:44 +0000
336@@ -19,6 +19,7 @@
337 from zope.interface.verify import verifyObject
338 from zope.security.proxy import removeSecurityProxy
339
340+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
341 from canonical.launchpad.webapp.publisher import canonical_url
342 from canonical.testing.layers import (
343 LaunchpadZopelessLayer,
344@@ -33,9 +34,22 @@
345 from lp.translations.interfaces.translationcommonformat import (
346 ITranslationFileData,
347 )
348+from lp.translations.interfaces.translationgroup import TranslationPermission
349 from lp.translations.interfaces.translationmessage import (
350 TranslationValidationStatus,
351 )
352+from lp.translations.interfaces.translationsperson import ITranslationsPerson
353+
354+
355+def set_relicensing(person, choice):
356+ """Set `person`'s choice for the translations relicensing agreement.
357+
358+ :param person: A `Person`.
359+ :param choice: The person's tri-state boolean choice on the
360+ relicensing agreement. None means undecided, which is the
361+ default initial choice for any person.
362+ """
363+ ITranslationsPerson(person).translations_relicensing_agreement = choice
364
365
366 class TestTranslationSharedPOFile(TestCaseWithFactory):
367@@ -1973,3 +1987,177 @@
368 self.assertEqual(1, translation_file_data.header.number_plural_forms)
369 self.assertEqual(
370 u"0", translation_file_data.header.plural_form_expression)
371+
372+
373+class TestPOFilePermissions(TestCaseWithFactory):
374+ """Test `POFile` access privileges.
375+
376+ :ivar pofile: A `POFile` for a `ProductSeries`.
377+ """
378+ layer = ZopelessDatabaseLayer
379+
380+ def setUp(self):
381+ super(TestPOFilePermissions, self).setUp()
382+ self.pofile = self.factory.makePOFile()
383+
384+ def makeDistroPOFile(self):
385+ """Replace `self.pofile` with one for a `Distribution`."""
386+ template = self.factory.makePOTemplate(
387+ distroseries=self.factory.makeDistroSeries(),
388+ sourcepackagename=self.factory.makeSourcePackageName())
389+ self.pofile = self.factory.makePOFile(potemplate=template)
390+
391+ def getTranslationPillar(self):
392+ """Return `self.pofile`'s `Product` or `Distribution`."""
393+ template = self.pofile.potemplate
394+ if template.productseries is not None:
395+ return template.productseries.product
396+ else:
397+ return template.distroseries.distribution
398+
399+ def closeTranslations(self):
400+ """Set translation permissions for `self.pofile` to CLOSED.
401+
402+ This is useful for showing that a particular person has rights
403+ to work on a translation despite it being generally closed to
404+ the public.
405+ """
406+ self.getTranslationPillar().translationpermission = (
407+ TranslationPermission.CLOSED)
408+
409+ def test_makeDistroPOFile(self):
410+ # Test the makeDistroPOFile helper.
411+ self.assertEqual(
412+ self.pofile.potemplate.productseries.product,
413+ self.getTranslationPillar())
414+ self.makeDistroPOFile()
415+ self.assertEqual(
416+ self.pofile.potemplate.distroseries.distribution,
417+ self.getTranslationPillar())
418+
419+ def test_closeTranslations_product(self):
420+ # Test the closeTranslations helper for Products.
421+ self.assertNotEqual(
422+ TranslationPermission.CLOSED,
423+ self.getTranslationPillar().translationpermission)
424+ self.closeTranslations()
425+ self.assertEqual(
426+ TranslationPermission.CLOSED,
427+ self.getTranslationPillar().translationpermission)
428+
429+ def test_closeTranslations_distro(self):
430+ # Test the closeTranslations helper for Distributions.
431+ self.makeDistroPOFile()
432+ self.assertNotEqual(
433+ TranslationPermission.CLOSED,
434+ self.getTranslationPillar().translationpermission)
435+ self.closeTranslations()
436+ self.assertEqual(
437+ TranslationPermission.CLOSED,
438+ self.getTranslationPillar().translationpermission)
439+
440+ def test_anonymous_cannot_submit(self):
441+ # Anonymous users cannot edit translations or enter suggestions.
442+ self.assertFalse(self.pofile.canEditTranslations(None))
443+ self.assertFalse(self.pofile.canAddSuggestions(None))
444+
445+ def test_licensing_agreement_decliners_cannot_submit(self):
446+ # Users who decline the translations relicensing agreement can't
447+ # edit translations or enter suggestions.
448+ decliner = self.factory.makePerson()
449+ set_relicensing(decliner, False)
450+ self.assertFalse(self.pofile.canEditTranslations(decliner))
451+ self.assertFalse(self.pofile.canAddSuggestions(decliner))
452+
453+ def test_licensing_agreement_accepters_can_submit(self):
454+ # Users who accept the translations relicensing agreement can
455+ # edit translations and enter suggestions as circumstances
456+ # allow.
457+ accepter = self.factory.makePerson()
458+ set_relicensing(accepter, True)
459+ self.assertTrue(self.pofile.canEditTranslations(accepter))
460+ self.assertTrue(self.pofile.canAddSuggestions(accepter))
461+
462+ def test_admin_can_edit(self):
463+ # Administrators can edit all translations and make suggestions
464+ # anywhere.
465+ self.closeTranslations()
466+ admin = self.factory.makePerson()
467+ getUtility(ILaunchpadCelebrities).admin.addMember(admin, admin)
468+ self.assertTrue(self.pofile.canEditTranslations(admin))
469+ self.assertTrue(self.pofile.canAddSuggestions(admin))
470+
471+ def test_translations_admin_can_edit(self):
472+ # Translations admins can edit all translations and make
473+ # suggestions anywhere.
474+ self.closeTranslations()
475+ translations_admin = self.factory.makePerson()
476+ getUtility(ILaunchpadCelebrities).rosetta_experts.addMember(
477+ translations_admin, translations_admin)
478+ self.assertTrue(self.pofile.canEditTranslations(translations_admin))
479+ self.assertTrue(self.pofile.canAddSuggestions(translations_admin))
480+
481+ def test_product_owner_can_edit(self):
482+ # A Product owner can edit the Product's translations and enter
483+ # suggestions even when a regular user isn't allowed to.
484+ self.closeTranslations()
485+ product = self.getTranslationPillar()
486+ self.assertTrue(self.pofile.canEditTranslations(product.owner))
487+ self.assertTrue(self.pofile.canAddSuggestions(product.owner))
488+
489+ def test_product_owner_can_edit_after_declining_agreement(self):
490+ # A Product owner can edit the Product's translations even after
491+ # declining the translations licensing agreement.
492+ product = self.getTranslationPillar()
493+ set_relicensing(product.owner, False)
494+ self.assertTrue(self.pofile.canEditTranslations(product.owner))
495+
496+ def test_distro_owner_gets_no_privileges(self):
497+ # A Distribution owner gets no special privileges.
498+ self.makeDistroPOFile()
499+ self.closeTranslations()
500+ distro = self.getTranslationPillar()
501+ self.assertFalse(self.pofile.canEditTranslations(distro.owner))
502+ self.assertFalse(self.pofile.canAddSuggestions(distro.owner))
503+
504+ def test_productseries_owner_gets_no_privileges(self):
505+ # A ProductSeries owner gets no special privileges.
506+ self.closeTranslations()
507+ productseries = self.pofile.potemplate.productseries
508+ productseries.owner = self.factory.makePerson()
509+ self.assertFalse(self.pofile.canEditTranslations(productseries.owner))
510+ self.assertFalse(self.pofile.canAddSuggestions(productseries.owner))
511+
512+ def test_potemplate_owner_gets_no_privileges(self):
513+ # A POTemplate owner gets no special privileges.
514+ self.closeTranslations()
515+ template = self.pofile.potemplate
516+ template.owner = self.factory.makePerson()
517+ self.assertFalse(self.pofile.canEditTranslations(template.owner))
518+ self.assertFalse(self.pofile.canAddSuggestions(template.owner))
519+
520+ def test_pofile_owner_can_edit(self):
521+ # A POFile owner currently has special edit privileges.
522+ self.closeTranslations()
523+ self.pofile.owner = self.factory.makePerson()
524+ self.assertTrue(self.pofile.canEditTranslations(self.pofile.owner))
525+ self.assertTrue(self.pofile.canAddSuggestions(self.pofile.owner))
526+
527+ def test_product_translation_group_owner_gets_no_privileges(self):
528+ # A translation group owner manages the translation group
529+ # itself. There are no special privileges.
530+ self.closeTranslations()
531+ group = self.factory.makeTranslationGroup()
532+ self.getTranslationPillar().translationgroup = group
533+ self.assertFalse(self.pofile.canEditTranslations(group.owner))
534+ self.assertFalse(self.pofile.canAddSuggestions(group.owner))
535+
536+ def test_distro_translation_group_owner_gets_no_privileges(self):
537+ # Owners of Distribution translation groups get no special edit
538+ # privileges.
539+ self.makeDistroPOFile()
540+ self.closeTranslations()
541+ group = self.factory.makeTranslationGroup()
542+ self.getTranslationPillar().translationgroup = group
543+ self.assertFalse(self.pofile.canEditTranslations(group.owner))
544+ self.assertFalse(self.pofile.canAddSuggestions(group.owner))
545
546=== added file 'lib/lp/translations/tests/test_translationpermission.py'
547--- lib/lp/translations/tests/test_translationpermission.py 1970-01-01 00:00:00 +0000
548+++ lib/lp/translations/tests/test_translationpermission.py 2010-11-08 08:47:44 +0000
549@@ -0,0 +1,300 @@
550+# Copyright 2010 Canonical Ltd. This software is licensed under the
551+# GNU Affero General Public License version 3 (see the file LICENSE).
552+
553+"""Test the translation permissions model."""
554+
555+__metaclass__ = type
556+
557+from zope.component import getUtility
558+
559+from canonical.testing.layers import ZopelessDatabaseLayer
560+from lp.testing import TestCaseWithFactory
561+from lp.translations.interfaces.translationgroup import TranslationPermission
562+from lp.translations.interfaces.translator import ITranslatorSet
563+
564+
565+# Description of the translations permissions model:
566+# * OPEN lets anyone edit or suggest.
567+# * STRUCTURED lets translation team members edit and anyone
568+# suggest, but acts like OPEN when no translation team
569+# applies.
570+# * RESTRICTED lets translation team members edit and anyone
571+# suggest, but acts like CLOSED when no translation team
572+# applies.
573+# * CLOSED lets only translation team members edit translations
574+# or enter suggestions.
575+translation_permissions = [
576+ TranslationPermission.OPEN,
577+ TranslationPermission.STRUCTURED,
578+ TranslationPermission.RESTRICTED,
579+ TranslationPermission.CLOSED,
580+ ]
581+
582+# A user can be translating either a translation that's not covered by a
583+# translation team ("untended"), or one that is ("tended"), or one whose
584+# translation team the user is a member of ("member").
585+team_coverage = [
586+ 'untended',
587+ 'tended',
588+ 'member',
589+ ]
590+
591+
592+class PrivilegeLevel:
593+ """What is a given user allowed to do with a given translation?"""
594+ NOTHING = 'Nothing'
595+ SUGGEST = 'Suggest only'
596+ EDIT = 'Edit'
597+
598+ _level_mapping = {
599+ (False, False): NOTHING,
600+ (False, True): SUGGEST,
601+ (True, True): EDIT,
602+ }
603+
604+ @classmethod
605+ def check(cls, pofile, user):
606+ """Return privilege level that `user` has on `pofile`."""
607+ can_edit = pofile.canEditTranslations(user)
608+ can_suggest = pofile.canAddSuggestions(user)
609+ return cls._level_mapping[can_edit, can_suggest]
610+
611+
612+permissions_model = {
613+ (TranslationPermission.OPEN, 'untended'): PrivilegeLevel.EDIT,
614+ (TranslationPermission.OPEN, 'tended'): PrivilegeLevel.EDIT,
615+ (TranslationPermission.OPEN, 'member'): PrivilegeLevel.EDIT,
616+ (TranslationPermission.STRUCTURED, 'untended'): PrivilegeLevel.EDIT,
617+ (TranslationPermission.STRUCTURED, 'tended'): PrivilegeLevel.SUGGEST,
618+ (TranslationPermission.STRUCTURED, 'member'): PrivilegeLevel.EDIT,
619+ (TranslationPermission.RESTRICTED, 'untended'): PrivilegeLevel.NOTHING,
620+ (TranslationPermission.RESTRICTED, 'tended'): PrivilegeLevel.SUGGEST,
621+ (TranslationPermission.RESTRICTED, 'member'): PrivilegeLevel.EDIT,
622+ (TranslationPermission.CLOSED, 'untended'): PrivilegeLevel.NOTHING,
623+ (TranslationPermission.CLOSED, 'tended'): PrivilegeLevel.NOTHING,
624+ (TranslationPermission.CLOSED, 'member'): PrivilegeLevel.EDIT,
625+}
626+
627+
628+def combine_permissions(product):
629+ """Return the effective translation permission for `product`.
630+
631+ This combines the translation permissions for `product` and
632+ `product.project`.
633+ """
634+ return max(
635+ product.project.translationpermission, product.translationpermission)
636+
637+
638+class TestTranslationPermission(TestCaseWithFactory):
639+ layer = ZopelessDatabaseLayer
640+
641+ def makeProductInProjectGroup(self):
642+ """Create a `Product` that's in a `ProjectGroup`."""
643+ project = self.factory.makeProject()
644+ return self.factory.makeProduct(project=project)
645+
646+ def closeTranslations(self, product):
647+ """Set translation permissions for `product` to Closed.
648+
649+ If `product` is part of a project group, the project group's
650+ translation permissions are set to Closed as well.
651+
652+ This is useful for showing that a particular person has
653+ rights to work on a translation despite it being generally
654+ closed to the public.
655+ """
656+ product.translationpermission = TranslationPermission.CLOSED
657+ if product.project is not None:
658+ product.project.translationpermission = (
659+ TranslationPermission.CLOSED)
660+
661+ def makePOTemplateForProduct(self, product):
662+ """Create a `POTemplate` for a given `Product`."""
663+ return self.factory.makePOTemplate(
664+ productseries=self.factory.makeProductSeries(product=product))
665+
666+ def makePOFileForProduct(self, product):
667+ """Create a `POFile` for a given `Product`."""
668+ return self.factory.makePOFile(
669+ potemplate=self.makePOTemplateForProduct(product))
670+
671+ def makeTranslationTeam(self, group, language, members=None):
672+ """Create a translation team containing `person`.
673+
674+ If `members` is None, a member will be created.
675+ """
676+ if members is None:
677+ members = [self.factory.makePerson()]
678+ team = self.factory.makeTeam(members=members)
679+ getUtility(ITranslatorSet).new(group, language, team)
680+ return team
681+
682+ def makePOFilesForCoverageLevels(self, product, user):
683+ """Map each `team_coverage` level to a matching `POFile`.
684+
685+ Produces a dict mapping containing one `POFile` for each
686+ coverage level:
687+ * 'untended' maps to a `POFile` not covered by a translation
688+ team.
689+ * 'tended' maps to a `POFile` covered by a translation team
690+ that `user` is not a member of.
691+ * 'member' maps to a `POFile` covered by a translation team
692+ that `user` is a member of.
693+
694+ All `POFile`s are for the same `POTemplate`, on `product`.
695+ """
696+ potemplate = self.makePOTemplateForProduct(product)
697+ group = self.factory.makeTranslationGroup()
698+ potemplate.productseries.product.translationgroup = group
699+ pofiles = dict(
700+ (coverage, self.factory.makePOFile(potemplate=potemplate))
701+ for coverage in team_coverage)
702+ self.makeTranslationTeam(group, pofiles['tended'].language)
703+ self.makeTranslationTeam(
704+ group, pofiles['member'].language, members=[user])
705+ return pofiles
706+
707+ def assertPrivilege(self, permission, coverage, privilege_level):
708+ """Assert that `privilege_level` is as the model says it should be."""
709+ self.assertEqual(
710+ permissions_model[permission, coverage],
711+ privilege_level,
712+ "Wrong privileges for %s with translation team coverage '%s'." % (
713+ permission, coverage))
714+
715+ def test_translationgroup_models(self):
716+ # Test that a translation group bestows the expected privilege
717+ # level to a user for each possible combination of
718+ # TranslationPermission, existence of a translation team, and
719+ # the user's membership of a translation team.
720+ user = self.factory.makePerson()
721+ product = self.factory.makeProduct()
722+ pofiles = self.makePOFilesForCoverageLevels(product, user)
723+ for permission in translation_permissions:
724+ product.translationpermission = permission
725+ for coverage in team_coverage:
726+ pofile = pofiles[coverage]
727+ privilege_level = PrivilegeLevel.check(pofile, user)
728+ self.assertPrivilege(permission, coverage, privilege_level)
729+
730+ def test_translationgroupless_models(self):
731+ # In the absence of a translation group, translation models
732+ # behave as if there were a group that did not cover any
733+ # languages (and which no user is ever a member of).
734+ user = self.factory.makePerson()
735+ pofile = self.factory.makePOFile()
736+ product = pofile.potemplate.productseries.product
737+ for permission in translation_permissions:
738+ product.translationpermission = permission
739+ privilege_level = PrivilegeLevel.check(pofile, user)
740+ self.assertPrivilege(permission, 'untended', privilege_level)
741+
742+ def test_projectgroup_stands_in_for_product(self):
743+ # If a Product has no translation group but its project group
744+ # does, the project group's translation group applies.
745+ product = self.makeProductInProjectGroup()
746+ self.closeTranslations(product)
747+ user = self.factory.makePerson()
748+ group = self.factory.makeTranslationGroup()
749+ product.project.translationgroup = group
750+ pofile = self.makePOFileForProduct(product)
751+ getUtility(ITranslatorSet).new(group, pofile.language, user)
752+
753+ self.assertTrue(pofile.canEditTranslations(user))
754+
755+ def test_projectgroup_and_product_combine_translation_teams(self):
756+ # If a Product with a translation group is in a project group
757+ # that also has a translation group, the product's translation
758+ # teams are effectively the unions of the two translation
759+ # groups' respective teams.
760+ product = self.makeProductInProjectGroup()
761+ self.closeTranslations(product)
762+ pofile = self.makePOFileForProduct(product)
763+ product_translator = self.factory.makePerson()
764+ project_translator = self.factory.makePerson()
765+ product.project.translationgroup = self.factory.makeTranslationGroup()
766+ product.translationgroup = self.factory.makeTranslationGroup()
767+ self.makeTranslationTeam(
768+ product.project.translationgroup, pofile.language,
769+ [project_translator])
770+ self.makeTranslationTeam(
771+ product.translationgroup, pofile.language, [product_translator])
772+
773+ # Both the translator from the project group's translation team
774+ # and the one from the product's translation team have edit
775+ # privileges on the translation.
776+ self.assertTrue(pofile.canEditTranslations(project_translator))
777+ self.assertTrue(pofile.canEditTranslations(product_translator))
778+
779+ def test_projectgroup_and_product_permissions_combine(self):
780+ # If a product is in a project group, each has a translation
781+ # permission. The two are combined to produce a single
782+ # effective permission.
783+ product = self.makeProductInProjectGroup()
784+ user = self.factory.makePerson()
785+ pofiles = self.makePOFilesForCoverageLevels(product, user)
786+ for project_permission in translation_permissions:
787+ product.project.translationpermission = project_permission
788+ for product_permission in translation_permissions:
789+ product.translationpermission = product_permission
790+ effective_permission = combine_permissions(product)
791+
792+ for coverage in team_coverage:
793+ pofile = pofiles[coverage]
794+ privilege_level = PrivilegeLevel.check(pofile, user)
795+ self.assertPrivilege(
796+ effective_permission, coverage, privilege_level)
797+
798+ def test_combine_permissions_yields_strictest(self):
799+ # Combining the translation permissions of a product and its
800+ # project group yields the strictest of the two.
801+ product = self.makeProductInProjectGroup()
802+
803+ # The expected combined permission for each combination of
804+ # project-group and product permissions.
805+ combinations = {
806+ TranslationPermission.OPEN: {
807+ TranslationPermission.OPEN: TranslationPermission.OPEN,
808+ TranslationPermission.STRUCTURED:
809+ TranslationPermission.STRUCTURED,
810+ TranslationPermission.RESTRICTED:
811+ TranslationPermission.RESTRICTED,
812+ TranslationPermission.CLOSED: TranslationPermission.CLOSED,
813+ },
814+ TranslationPermission.STRUCTURED: {
815+ TranslationPermission.OPEN: TranslationPermission.STRUCTURED,
816+ TranslationPermission.STRUCTURED:
817+ TranslationPermission.STRUCTURED,
818+ TranslationPermission.RESTRICTED:
819+ TranslationPermission.RESTRICTED,
820+ TranslationPermission.CLOSED: TranslationPermission.CLOSED,
821+ },
822+ TranslationPermission.RESTRICTED: {
823+ TranslationPermission.OPEN: TranslationPermission.RESTRICTED,
824+ TranslationPermission.STRUCTURED:
825+ TranslationPermission.RESTRICTED,
826+ TranslationPermission.RESTRICTED:
827+ TranslationPermission.RESTRICTED,
828+ TranslationPermission.CLOSED: TranslationPermission.CLOSED,
829+ },
830+ TranslationPermission.CLOSED: {
831+ TranslationPermission.OPEN: TranslationPermission.CLOSED,
832+ TranslationPermission.STRUCTURED:
833+ TranslationPermission.CLOSED,
834+ TranslationPermission.RESTRICTED:
835+ TranslationPermission.CLOSED,
836+ TranslationPermission.CLOSED: TranslationPermission.CLOSED,
837+ },
838+ }
839+
840+ # The strictest of Open and something else is always the
841+ # something else.
842+ for project_permission in translation_permissions:
843+ product.project.translationpermission = project_permission
844+ for product_permission in translation_permissions:
845+ product.translationpermission = product_permission
846+ expected_permission = (
847+ combinations[project_permission][product_permission])
848+ self.assertEqual(
849+ expected_permission, combine_permissions(product))