← Back to team overview

launchpad-reviewers team mailing list archive

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

 

Jeroen T. Vermeulen has proposed merging lp:~jtv/launchpad/test-pofile-permissions into lp:launchpad/devel.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers): code


= 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
-- 
https://code.launchpad.net/~jtv/launchpad/test-pofile-permissions/+merge/40058
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jtv/launchpad/test-pofile-permissions into lp:launchpad/devel.
=== modified file 'lib/lp/translations/doc/pofile.txt'
--- lib/lp/translations/doc/pofile.txt	2010-10-18 22:24:59 +0000
+++ lib/lp/translations/doc/pofile.txt	2010-11-04 08:31:59 +0000
@@ -383,241 +383,6 @@
     False
 
 
-canEditTranslations
--------------------
-
-This method determines if someone is allowed to edit translations.
-
-Do some needed imports.
-
-    >>> from canonical.launchpad.interfaces.launchpad import (
-    ...     ILaunchpadCelebrities)
-    >>> from lp.registry.interfaces.product import IProductSet
-    >>> from lp.translations.interfaces.translationgroup import (
-    ...     ITranslationGroupSet, TranslationPermission)
-    >>> from canonical.launchpad.ftests import login
-    >>> from lp.translations.model.pofile import POFile
-    >>> person_set = getUtility(IPersonSet)
-
-Need extra permissions to change the values.
-
-    >>> login('carlos@xxxxxxxxxxxxx')
-
-Set a translation group to test the CLOSED mode. This mode allows
-translations only from the teams set as official translators.
-
-    >>> product = getUtility(IProductSet).getByName('evolution')
-    >>> translation_group_set = getUtility(ITranslationGroupSet)
-    >>> product.translationgroup = translation_group_set[
-    ...     'testing-translation-team']
-    >>> product.translationpermission = TranslationPermission.CLOSED
-
-Get the IPOFile we are going to use.
-
-    >>> product_series = product.translatable_series[0]
-    >>> potemplate = product_series.getPOTemplate('evolution-2.2')
-    >>> pofile_es = potemplate.getPOFileByLang('es')
-
-A Launchpad admin must have permission always.
-
-    >>> admins = getUtility(ILaunchpadCelebrities).admin
-    >>> pofile_es.canEditTranslations(admins)
-    True
-
-A Rosetta Expert too.
-
-    >>> rosetta_experts = getUtility(ILaunchpadCelebrities).rosetta_experts
-    >>> pofile_es.canEditTranslations(rosetta_experts)
-    True
-
-And Valentina Commissari, as member of the Spanish translation team for
-evolution should also have rights.
-
-    >>> valentina = person_set.getByName('tsukimi')
-    >>> pofile_es.canEditTranslations(valentina)
-    True
-
-But the unprivileged account should not.
-
-    >>> no_priv = person_set.getByName('no-priv')
-    >>> pofile_es.canEditTranslations(no_priv)
-    False
-
-And if he tries to update translations, the system blocks such breakage.
-
-    >>> potmsgset = pofile_es.potemplate.getPOTMsgSetByMsgIDText(
-    ...     singular_text=u'evolution addressbook')
-    >>> translation_message = potmsgset.getCurrentTranslationMessage(
-    ...     pofile_es.potemplate, pofile_es.language)
-    >>> is_imported = False
-    >>> lock_timestamp = datetime.datetime.now(UTC)
-    >>> translation_message.potmsgset.updateTranslation(
-    ...     pofile_es, no_priv, [u'foo'], is_imported, lock_timestamp)
-    Traceback (most recent call last):
-    ...
-    AssertionError: No Privileges Person cannot add suggestions here.
-
-Now, we get an IPOFile that does not have a translation team assigned.
-
-    >>> language_cy = getUtility(ILanguageSet).getLanguageByCode('cy')
-    >>> pofile_cy = potemplate.getDummyPOFile(language_cy)
-
-Valentina Commissari is not a translator for this language and does not
-have permissions.
-
-    >>> pofile_cy.canEditTranslations(valentina)
-    False
-
-And same thing with the unprivileged account.
-
-    >>> pofile_cy.canEditTranslations(no_priv)
-    False
-
-RESTRICTED mode is the same as CLOSED when restricting who is able to
-change translations.
-
-    >>> product.translationpermission = TranslationPermission.RESTRICTED
-
-A Launchpad admin must have permission always.
-
-    >>> pofile_es.canEditTranslations(admins)
-    True
-
-A Translations Expert too.
-
-    >>> pofile_es.canEditTranslations(rosetta_experts)
-    True
-
-And Valentina Commissari, as member of the Spanish translation team for
-evolution should also have rights.
-
-    >>> pofile_es.canEditTranslations(valentina)
-    True
-
-But the unprivileged account should not.
-
-    >>> pofile_es.canEditTranslations(no_priv)
-    False
-
-Valentina Commissari still doesn't have permissions to edit translations
-for Welsh (cy).
-
-    >>> pofile_cy.canEditTranslations(valentina)
-    False
-
-And same thing with the unprivileged account.
-
-    >>> pofile_cy.canEditTranslations(no_priv)
-    False
-
-Now, let's test the STRUCTURED mode. In this mode, only the defined
-translation teams can translate like the RESTRICTED and CLOSED mode, but
-in addition, if we don't have any language team for one language, anyone
-can add translations.
-
-    >>> product.translationpermission = TranslationPermission.STRUCTURED
-
-Valentina Commissari, as member of the Spanish translation team for
-evolution should have rights for the Spanish IPOFile.
-
-    >>> pofile_es.canEditTranslations(valentina)
-    True
-
-But the unprivileged account should not.
-
-    >>> pofile_es.canEditTranslations(no_priv)
-    False
-
-And this is the difference with the CLOSED mode, anyone will be able to
-translate into Welsh, as we can see with Valentina:
-
-    >>> pofile_cy.canEditTranslations(valentina)
-    True
-
-And same thing with the unprivileged account.
-
-    >>> pofile_cy.canEditTranslations(no_priv)
-    True
-
-Finally, let's check the OPEN mode to be 100% sure that in that mode
-anyone can do translations.
-
-    >>> product.translationgroup = None
-    >>> product.translationpermission = TranslationPermission.OPEN
-
-We don't have any translation group for the Evolution product so there
-are no translators assigned to it, but Valentina Commissari still has
-rights to do translations.
-
-    >>> pofile_es.canEditTranslations(valentina)
-    True
-
-And samething with the unprivileged account.
-
-    >>> pofile_es.canEditTranslations(no_priv)
-    True
-
-
-canAddSuggestions
------------------
-
-This method determines if someone is allowed to add suggestions.
-
-Set a translation group to test the CLOSED mode. This mode allows
-translations only from the teams set as official translators.
-
-    >>> product.translationgroup = translation_group_set[
-    ...     'testing-translation-team']
-    >>> product.translationpermission = TranslationPermission.CLOSED
-
-A Launchpad admin must have permission always.
-
-    >>> pofile_es.canAddSuggestions(admins)
-    True
-
-A Translations Expert too.
-
-    >>> pofile_es.canAddSuggestions(rosetta_experts)
-    True
-
-And Valentina Commissari, as member of the Spanish translation team for
-evolution should also have rights.
-
-    >>> pofile_es.canAddSuggestions(valentina)
-    True
-
-But the unprivileged account should not.
-
-    >>> pofile_es.canAddSuggestions(no_priv)
-    False
-
-RESTRICTED, STRUCTURED and OPEN modes are different from CLOSED mode
-when handling suggestions because it allows anyone to add suggestions.
-
-    >>> def canAddSuggestionsCheck(translation_mode):
-    ...     product.translationpermission = translation_mode
-    ...     assert pofile_es.canAddSuggestions(admins), (
-    ...         'Administrators are not able to add suggestions!')
-    ...     assert pofile_es.canAddSuggestions(rosetta_experts), (
-    ...         'Translation experts are not able to add suggestions!')
-    ...     assert pofile_es.canAddSuggestions(no_priv), (
-    ...         'A plain user is not able to add suggestions!')
-    ...     return True
-
-    >>> canAddSuggestionsCheck(TranslationPermission.RESTRICTED)
-    True
-
-    >>> canAddSuggestionsCheck(TranslationPermission.STRUCTURED)
-    True
-
-    >>> canAddSuggestionsCheck(TranslationPermission.OPEN)
-    True
-
-    Leave the permission back to OPEN.
-
-    >>> product.translationpermission = TranslationPermission.OPEN
-
-
 plural_forms
 ------------
 

=== modified file 'lib/lp/translations/tests/test_pofile.py'
--- lib/lp/translations/tests/test_pofile.py	2010-10-05 00:08:16 +0000
+++ lib/lp/translations/tests/test_pofile.py	2010-11-04 08:31:59 +0000
@@ -19,6 +19,7 @@
 from zope.interface.verify import verifyObject
 from zope.security.proxy import removeSecurityProxy
 
+from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
 from canonical.launchpad.webapp.publisher import canonical_url
 from canonical.testing.layers import (
     LaunchpadZopelessLayer,
@@ -33,9 +34,22 @@
 from lp.translations.interfaces.translationcommonformat import (
     ITranslationFileData,
     )
+from lp.translations.interfaces.translationgroup import TranslationPermission
 from lp.translations.interfaces.translationmessage import (
     TranslationValidationStatus,
     )
+from lp.translations.interfaces.translationsperson import ITranslationsPerson
+
+
+def set_relicensing(person, choice):
+    """Set `person`'s choice for the translations relicensing agreement.
+
+    :param person: A `Person`.
+    :param choice: The person's tri-state boolean choice on the
+        relicensing agreement.  None means undecided, which is the
+        default initial choice for any person.
+    """
+    ITranslationsPerson(person).translations_relicensing_agreement = choice
 
 
 class TestTranslationSharedPOFile(TestCaseWithFactory):
@@ -1973,3 +1987,172 @@
         self.assertEqual(1, translation_file_data.header.number_plural_forms)
         self.assertEqual(
             u"0", translation_file_data.header.plural_form_expression)
+
+
+class TestPOFilePermissions(TestCaseWithFactory):
+    """Test `POFile` access privileges.
+
+        :ivar pofile: A `POFile` for a `ProductSeries`.
+    """
+    layer = ZopelessDatabaseLayer
+
+    def setUp(self):
+        super(TestPOFilePermissions, self).setUp()
+        self.pofile = self.factory.makePOFile()
+
+    def makeDistroPOFile(self):
+        """Replace `self.pofile` with one for a `Distribution`."""
+        template = self.factory.makePOTemplate(
+            distroseries=self.factory.makeDistroSeries(),
+            sourcepackagename=self.factory.makeSourcePackageName())
+        self.pofile = self.factory.makePOFile(potemplate=template)
+
+    def getTranslationPillar(self):
+        """Return `self.pofile`'s `Product` or `Distribution`."""
+        template = self.pofile.potemplate
+        if template.productseries is not None:
+            return template.productseries.product
+        else:
+            return template.distroseries.distribution
+
+    def closeTranslations(self):
+        """Set translation permissions for `self.pofile` to CLOSED."""
+        self.getTranslationPillar().translationpermission = (
+            TranslationPermission.CLOSED)
+
+    def test_makeDistroPOFile(self):
+        # Test the makeDistroPOFile helper.
+        self.assertEqual(
+            self.pofile.potemplate.productseries.product,
+            self.getTranslationPillar())
+        self.makeDistroPOFile()
+        self.assertEqual(
+            self.pofile.potemplate.distroseries.distribution,
+            self.getTranslationPillar())
+
+    def test_closeTranslations_product(self):
+        # Test the closeTranslations helper for Products.
+        self.assertNotEqual(
+            TranslationPermission.CLOSED,
+            self.getTranslationPillar().translationpermission)
+        self.closeTranslations()
+        self.assertEqual(
+            TranslationPermission.CLOSED,
+            self.getTranslationPillar().translationpermission)
+
+    def test_closeTranslations_distro(self):
+        # Test the closeTranslations helper for Distributions.
+        self.makeDistroPOFile()
+        self.assertNotEqual(
+            TranslationPermission.CLOSED,
+            self.getTranslationPillar().translationpermission)
+        self.closeTranslations()
+        self.assertEqual(
+            TranslationPermission.CLOSED,
+            self.getTranslationPillar().translationpermission)
+
+    def test_anonymous_cannot_submit(self):
+        # Anonymous users cannot edit translations or enter suggestions.
+        self.assertFalse(self.pofile.canEditTranslations(None))
+        self.assertFalse(self.pofile.canAddSuggestions(None))
+
+    def test_licensing_agreement_decliners_cannot_submit(self):
+        # Users who decline the translations relicensing agreement can't
+        # edit translations or enter suggestions.
+        decliner = self.factory.makePerson()
+        set_relicensing(decliner, False)
+        self.assertFalse(self.pofile.canEditTranslations(decliner))
+        self.assertFalse(self.pofile.canAddSuggestions(decliner))
+
+    def test_licensing_agreement_accepters_can_submit(self):
+        # Users who accept the translations relicensing agreement can
+        # edit translations and enter suggestions as circumstances
+        # allow.
+        accepter = self.factory.makePerson()
+        set_relicensing(accepter, True)
+        self.assertTrue(self.pofile.canEditTranslations(accepter))
+        self.assertTrue(self.pofile.canAddSuggestions(accepter))
+
+    def test_admin_can_edit(self):
+        # Administrators can edit all translations and make suggestions
+        # anywhere.
+        self.closeTranslations()
+        admin = self.factory.makePerson()
+        getUtility(ILaunchpadCelebrities).admin.addMember(admin, admin)
+        self.assertTrue(self.pofile.canEditTranslations(admin))
+        self.assertTrue(self.pofile.canAddSuggestions(admin))
+
+    def test_translations_admin_can_edit(self):
+        # Translations admins can edit all translations and make
+        # suggestions anywhere.
+        self.closeTranslations()
+        translations_admin = self.factory.makePerson()
+        getUtility(ILaunchpadCelebrities).rosetta_experts.addMember(
+            translations_admin, translations_admin)
+        self.assertTrue(self.pofile.canEditTranslations(translations_admin))
+        self.assertTrue(self.pofile.canAddSuggestions(translations_admin))
+
+    def test_product_owner_can_edit(self):
+        # A Product owner can edit the Product's translations and enter
+        # suggestions even when a regular user isn't allowed to.
+        self.closeTranslations()
+        product = self.getTranslationPillar()
+        self.assertTrue(self.pofile.canEditTranslations(product.owner))
+        self.assertTrue(self.pofile.canAddSuggestions(product.owner))
+
+    def test_product_owner_can_edit_after_declining_agreement(self):
+        # A Product owner can edit the Product's translations even after
+        # declining the translations licensing agreement.
+        product = self.getTranslationPillar()
+        set_relicensing(product.owner, False)
+        self.assertTrue(self.pofile.canEditTranslations(product.owner))
+
+    def test_distro_owner_gets_no_privileges(self):
+        # A Distribution owner gets no special privileges.
+        self.makeDistroPOFile()
+        self.closeTranslations()
+        distro = self.getTranslationPillar()
+        self.assertFalse(self.pofile.canEditTranslations(distro.owner))
+        self.assertFalse(self.pofile.canAddSuggestions(distro.owner))
+
+    def test_productseries_owner_gets_no_privileges(self):
+        # A ProductSeries owner gets no special privileges.
+        self.closeTranslations()
+        productseries = self.pofile.potemplate.productseries
+        productseries.owner = self.factory.makePerson()
+        self.assertFalse(self.pofile.canEditTranslations(productseries.owner))
+        self.assertFalse(self.pofile.canAddSuggestions(productseries.owner))
+
+    def test_potemplate_owner_gets_no_privileges(self):
+        # A POTemplate owner gets no special privileges.
+        self.closeTranslations()
+        template = self.pofile.potemplate
+        template.owner = self.factory.makePerson()
+        self.assertFalse(self.pofile.canEditTranslations(template.owner))
+        self.assertFalse(self.pofile.canAddSuggestions(template.owner))
+
+    def test_pofile_owner_can_edit(self):
+        # A POFile owner currently has special edit privileges.
+        self.closeTranslations()
+        self.pofile.owner = self.factory.makePerson()
+        self.assertTrue(self.pofile.canEditTranslations(self.pofile.owner))
+        self.assertTrue(self.pofile.canAddSuggestions(self.pofile.owner))
+
+    def test_product_translation_group_owner_gets_no_privileges(self):
+        # A translation group owner manages the translation group
+        # itself.  There are no special privileges.
+        self.closeTranslations()
+        group = self.factory.makeTranslationGroup()
+        self.getTranslationPillar().translationgroup = group
+        self.assertFalse(self.pofile.canEditTranslations(group.owner))
+        self.assertFalse(self.pofile.canAddSuggestions(group.owner))
+
+    def test_distro_translation_group_owner_gets_no_privileges(self):
+        # Owners of Distribution translation groups get no special edit
+        # privileges.
+        self.makeDistroPOFile()
+        self.closeTranslations()
+        group = self.factory.makeTranslationGroup()
+        self.getTranslationPillar().translationgroup = group
+        self.assertFalse(self.pofile.canEditTranslations(group.owner))
+        self.assertFalse(self.pofile.canAddSuggestions(group.owner))

=== added file 'lib/lp/translations/tests/test_translationpermission.py'
--- lib/lp/translations/tests/test_translationpermission.py	1970-01-01 00:00:00 +0000
+++ lib/lp/translations/tests/test_translationpermission.py	2010-11-04 08:31:59 +0000
@@ -0,0 +1,296 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test the translation permissions model."""
+
+__metaclass__ = type
+
+from zope.component import getUtility
+
+from canonical.testing.layers import ZopelessDatabaseLayer
+from lp.testing import TestCaseWithFactory
+from lp.translations.interfaces.translationgroup import TranslationPermission
+from lp.translations.interfaces.translator import ITranslatorSet
+
+
+# Description of the translations permissions model:
+# * OPEN lets anyone edit or suggest.
+# * STRUCTURED lets translation team members edit and anyone
+# suggest, but acts like OPEN when no translation team
+# applies.
+# * RESTRICTED lets translation team members edit and anyone
+# suggest, but acts like CLOSED when no translation team
+# applies.
+# * CLOSED lets only translation team members edit translations
+# or enter suggestions.
+translation_permissions = [
+    TranslationPermission.OPEN,
+    TranslationPermission.STRUCTURED,
+    TranslationPermission.RESTRICTED,
+    TranslationPermission.CLOSED,
+    ]
+
+# A user can be translating either a translation that's not covered by a
+# translation team ("untended"), or one that is ("tended"), or one whose
+# translation team the user is a member of ("member").
+team_coverage = [
+    'untended',
+    'tended',
+    'member',
+    ]
+
+
+class PrivilegeLevel:
+    """What is a given user allowed to do with a given translation?"""
+    NOTHING = 'Nothing'
+    SUGGEST = 'Suggest only'
+    EDIT = 'Edit'
+
+    _level_mapping = {
+        (False, False): NOTHING,
+        (False, True): SUGGEST,
+        (True, True): EDIT,
+    }
+
+    @classmethod
+    def check(cls, pofile, user):
+        """Return privilege level that `user` has on `pofile`."""
+        can_edit = pofile.canEditTranslations(user)
+        can_suggest = pofile.canAddSuggestions(user)
+        return cls._level_mapping[can_edit, can_suggest]
+
+
+permissions_model = {
+    (TranslationPermission.OPEN, 'untended'): PrivilegeLevel.EDIT,
+    (TranslationPermission.OPEN, 'tended'): PrivilegeLevel.EDIT,
+    (TranslationPermission.OPEN, 'member'): PrivilegeLevel.EDIT,
+    (TranslationPermission.STRUCTURED, 'untended'): PrivilegeLevel.EDIT,
+    (TranslationPermission.STRUCTURED, 'tended'): PrivilegeLevel.SUGGEST,
+    (TranslationPermission.STRUCTURED, 'member'): PrivilegeLevel.EDIT,
+    (TranslationPermission.RESTRICTED, 'untended'): PrivilegeLevel.NOTHING,
+    (TranslationPermission.RESTRICTED, 'tended'): PrivilegeLevel.SUGGEST,
+    (TranslationPermission.RESTRICTED, 'member'): PrivilegeLevel.EDIT,
+    (TranslationPermission.CLOSED, 'untended'): PrivilegeLevel.NOTHING,
+    (TranslationPermission.CLOSED, 'tended'): PrivilegeLevel.NOTHING,
+    (TranslationPermission.CLOSED, 'member'): PrivilegeLevel.EDIT,
+}
+
+
+def combine_permissions(product):
+    """Return the effective translation permission for `product`.
+
+    This combines the translation permissions for `product` and
+    `product.project`.
+    """
+    return max(
+        product.project.translationpermission, product.translationpermission)
+
+
+class TestTranslationPermission(TestCaseWithFactory):
+    layer = ZopelessDatabaseLayer
+
+    def makeProductInProjectGroup(self):
+        """Create a `Product` that's in a `ProjectGroup`."""
+        project = self.factory.makeProject()
+        return self.factory.makeProduct(project=project)
+
+    def closeTranslations(self, product):
+        """Set translation permissions for `product` to Closed.
+
+        If `product` is part of a project group, the project group's
+        translation permissions are set to Closed as well.
+        """
+        product.translationpermission = TranslationPermission.CLOSED
+        if product.project is not None:
+            product.project.translationpermission = (
+                TranslationPermission.CLOSED)
+
+    def makePOTemplateForProduct(self, product):
+        """Create a `POTemplate` for a given `Product`."""
+        return self.factory.makePOTemplate(
+            productseries=self.factory.makeProductSeries(product=product))
+
+    def makePOFileForProduct(self, product):
+        """Create a `POFile` for a given `Product`."""
+        return self.factory.makePOFile(
+            potemplate=self.makePOTemplateForProduct(product))
+
+    def makeTranslationTeam(self, group, language, members=None):
+        """Create a translation team containing `person`.
+
+        If `members` is None, a member will be created.
+        """
+        if members is None:
+            members = [self.factory.makePerson()]
+        team = self.factory.makeTeam(members=members)
+        getUtility(ITranslatorSet).new(group, language, team)
+        return team
+
+    def makePOFilesForCoverageLevels(self, product, user):
+        """Map each `team_coverage` level to a matching `POFile`.
+
+        Produces a dict mapping containing one `POFile` for each
+        coverage level:
+         * 'untended' maps to a `POFile` not covered by a translation
+           team.
+         * 'tended' maps to a `POFile` covered by a translation team
+           that `user` is not a member of.
+         * 'member' maps to a `POFile` covered by a translation team
+           that `user` is a member of.
+
+        All `POFile`s are for the same `POTemplate`, on `product`.
+        """
+        potemplate = self.makePOTemplateForProduct(product)
+        group = self.factory.makeTranslationGroup()
+        potemplate.productseries.product.translationgroup = group
+        pofiles = dict(
+            (coverage, self.factory.makePOFile(potemplate=potemplate))
+            for coverage in team_coverage)
+        self.makeTranslationTeam(group, pofiles['tended'].language)
+        self.makeTranslationTeam(
+            group, pofiles['member'].language, members=[user])
+        return pofiles
+
+    def assertPrivilege(self, permission, coverage, privilege_level):
+        """Assert that `privilege_level` is as the model says it should be."""
+        self.assertEqual(
+            permissions_model[permission, coverage],
+            privilege_level,
+            "Wrong privileges for %s with translation team coverage '%s'." % (
+                permission, coverage))
+
+    def test_translationgroup_models(self):
+        # Test that a translation group bestows the expected privilege
+        # level to a user for each possible combination of
+        # TranslationPermission, existence of a translation team, and
+        # the user's membership of a translation team.
+        user = self.factory.makePerson()
+        product = self.factory.makeProduct()
+        pofiles = self.makePOFilesForCoverageLevels(product, user)
+        for permission in translation_permissions:
+            product.translationpermission = permission
+            for coverage in team_coverage:
+                pofile = pofiles[coverage]
+                privilege_level = PrivilegeLevel.check(pofile, user)
+                self.assertPrivilege(permission, coverage, privilege_level)
+
+    def test_translationgroupless_models(self):
+        # In the absence of a translation group, translation models
+        # behave as if there were a group that did not cover any
+        # languages (and which no user is ever a member of).
+        user = self.factory.makePerson()
+        pofile = self.factory.makePOFile()
+        product = pofile.potemplate.productseries.product
+        for permission in translation_permissions:
+            product.translationpermission = permission
+            privilege_level = PrivilegeLevel.check(pofile, user)
+            self.assertPrivilege(permission, 'untended', privilege_level)
+
+    def test_projectgroup_stands_in_for_product(self):
+        # If a Product has no translation group but its project group
+        # does, the project group's translation group applies.
+        product = self.makeProductInProjectGroup()
+        self.closeTranslations(product)
+        user = self.factory.makePerson()
+        group = self.factory.makeTranslationGroup()
+        product.project.translationgroup = group
+        pofile = self.makePOFileForProduct(product)
+        getUtility(ITranslatorSet).new(group, pofile.language, user)
+
+        self.assertTrue(pofile.canEditTranslations(user))
+
+    def test_projectgroup_and_product_combine_translation_teams(self):
+        # If a Product with a translation group is in a project group
+        # that also has a translation group, the product's translation
+        # teams are effectively the unions of the two translation
+        # groups' respective teams.
+        product = self.makeProductInProjectGroup()
+        self.closeTranslations(product)
+        pofile = self.makePOFileForProduct(product)
+        product_translator = self.factory.makePerson()
+        project_translator = self.factory.makePerson()
+        product.project.translationgroup = self.factory.makeTranslationGroup()
+        product.translationgroup = self.factory.makeTranslationGroup()
+        self.makeTranslationTeam(
+            product.project.translationgroup, pofile.language,
+            [project_translator])
+        self.makeTranslationTeam(
+            product.translationgroup, pofile.language, [product_translator])
+
+        # Both the translator from the project group's translation team
+        # and the one from the product's translation team have edit
+        # privileges on the translation.
+        self.assertTrue(pofile.canEditTranslations(project_translator))
+        self.assertTrue(pofile.canEditTranslations(product_translator))
+
+    def test_projectgroup_and_product_permissions_combine(self):
+        # If a product is in a project group, each has a translation
+        # permission.  The two are combined to produce a single
+        # effective permission.
+        product = self.makeProductInProjectGroup()
+        user = self.factory.makePerson()
+        pofiles = self.makePOFilesForCoverageLevels(product, user)
+        for project_permission in translation_permissions:
+            product.project.translationpermission = project_permission
+            for product_permission in translation_permissions:
+                product.translationpermission = product_permission
+                effective_permission = combine_permissions(product)
+
+                for coverage in team_coverage:
+                    pofile = pofiles[coverage]
+                    privilege_level = PrivilegeLevel.check(pofile, user)
+                    self.assertPrivilege(
+                        effective_permission, coverage, privilege_level)
+
+    def test_combine_permissions_yields_strictest(self):
+        # Combining the translation permissions of a product and its
+        # project group yields the strictest of the two.
+        product = self.makeProductInProjectGroup()
+
+        # The expected combined permission for each combination of
+        # project-group and product permissions.
+        combinations = {
+            TranslationPermission.OPEN: {
+                TranslationPermission.OPEN: TranslationPermission.OPEN,
+                TranslationPermission.STRUCTURED:
+                    TranslationPermission.STRUCTURED,
+                TranslationPermission.RESTRICTED:
+                    TranslationPermission.RESTRICTED,
+                TranslationPermission.CLOSED: TranslationPermission.CLOSED,
+            },
+            TranslationPermission.STRUCTURED: {
+                TranslationPermission.OPEN: TranslationPermission.STRUCTURED,
+                TranslationPermission.STRUCTURED:
+                    TranslationPermission.STRUCTURED,
+                TranslationPermission.RESTRICTED:
+                    TranslationPermission.RESTRICTED,
+                TranslationPermission.CLOSED: TranslationPermission.CLOSED,
+            },
+            TranslationPermission.RESTRICTED: {
+                TranslationPermission.OPEN: TranslationPermission.RESTRICTED,
+                TranslationPermission.STRUCTURED:
+                    TranslationPermission.RESTRICTED,
+                TranslationPermission.RESTRICTED:
+                    TranslationPermission.RESTRICTED,
+                TranslationPermission.CLOSED: TranslationPermission.CLOSED,
+            },
+            TranslationPermission.CLOSED: {
+                TranslationPermission.OPEN: TranslationPermission.CLOSED,
+                TranslationPermission.STRUCTURED:
+                    TranslationPermission.CLOSED,
+                TranslationPermission.RESTRICTED:
+                    TranslationPermission.CLOSED,
+                TranslationPermission.CLOSED: TranslationPermission.CLOSED,
+            },
+        }
+
+        # The strictest of Open and something else is always the
+        # something else.
+        for project_permission in translation_permissions:
+            product.project.translationpermission = project_permission
+            for product_permission in translation_permissions:
+                product.translationpermission = product_permission
+                expected_permission = (
+                    combinations[project_permission][product_permission])
+                self.assertEqual(
+                    expected_permission, combine_permissions(product))


Follow ups