← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] lp:~danilo/launchpad/bug-321467 into lp:launchpad

 

Данило Шеган has proposed merging lp:~danilo/launchpad/bug-321467 into lp:launchpad.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)
Related bugs:
  Bug #321467 in Launchpad itself: "Template name is not validated on admin form."
  https://bugs.launchpad.net/launchpad/+bug/321467

For more details, see:
https://code.launchpad.net/~danilo/launchpad/bug-321467/+merge/72553

= Bug 321467: validate POTemplate name =

We recently exposed POTemplate:+admin page to a wider group (namely, project maintainers). However, +admin page was not sufficiently well doing the validation before trying to save the data which caused old OOPSes to become more frequent.

== Proposed fix ==

While just adding a valid_name validation for POTemplate name is a simple affair, I've went a bit further: I've decided to move only the relevant fields to the +edit page and still prohibit access to the +admin page for anyone but admins.

== Pre-implementation notes ==

I discussed Ubuntu-needed fields with David Planella, an Ubuntu Translations Coordinator.

== Implementation details ==

This meant a few things:
 1. We use launchpad.TranslationsAdmin where we used to use launchpad.Edit, and launchpad.Admin for launchpad.TranslationsAdmin privilege
 2. potemplate name is added to the POTemplateEditView.field_names and sourcepackagename and languagepack fields are added if the template is for a distro
 3. most of the validation code is moved from admin view into the edit view
 4. newly added check to the validation code is a valid_name(name) check
 5. POTemplateAdminView now derives from POTemplateEditView and only implements _validateTargetAndGetTemplates method which validates the specific bits it needs to
 6. test cases are expanded so they test all of the above
 7. we move the "Change details" (potemplate:+edit) link to be in the "Administration" section next to the "Administer template" (potemplate:+admin) link so it makes more sense
 8. _return_url never worked properly when template was moved around (it only worked when just "name" was changed), so I fix that along the way

Note: @@ -320,12 +321,11 @@ in browser/potemplate.py is a drive-by fix to remove the stale XXX and use proper code.

== Tests ==

bin/test -cvvt potemplate_views

(there are a few page test failures which I am fixing right now)

== Demo and Q/A ==

Log-in as one of (anonymous|regular user|project maintainer|rosetta expert|ubuntu translations coordinator team member) and try POTemplate:+edit and POTemplate:+admin pages from eg:

  https://translations.qastaging.launchpad.net/ubuntu/natty/+templates
  https://translations.qastaging.launchpad.net/openobject-addons/6.0/+templates

= Launchpad lint =

Checking for conflicts and issues in changed files.

Linting changed files:
  lib/lp/translations/browser/tests/test_potemplate_views.py
  lib/lp/translations/templates/potemplate-index.pt
  lib/lp/translations/browser/configure.zcml
  lib/lp/translations/interfaces/potemplate.py
  lib/canonical/launchpad/security.py
  lib/lp/translations/configure.zcml
  lib/lp/translations/browser/potemplate.py
-- 
https://code.launchpad.net/~danilo/launchpad/bug-321467/+merge/72553
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~danilo/launchpad/bug-321467 into lp:launchpad.
=== modified file 'lib/canonical/launchpad/security.py'
--- lib/canonical/launchpad/security.py	2011-08-19 14:57:16 +0000
+++ lib/canonical/launchpad/security.py	2011-08-23 11:39:41 +0000
@@ -1262,6 +1262,11 @@
     Product owners does not have administrative privileges.
     """
 
+    permission = 'launchpad.Admin'
+    usedfor = IPOTemplate
+
+
+class EditPOTemplateDetails(AuthorizationBase):
     permission = 'launchpad.TranslationsAdmin'
     usedfor = IPOTemplate
 
@@ -1269,30 +1274,11 @@
         template = self.obj
         if template.distroseries is not None:
             # Template is on a distribution.
-            return AdminDistroSeriesTranslations(
-                template.distroseries).checkAuthenticated(user)
+            return self.forwardCheckAuthenticated(user, template.distroseries)
         else:
             # Template is on a product.
-            return AdminProductSeriesTranslations(
-                template.productseries).checkAuthenticated(user)
-
-
-class EditPOTemplateDetails(AdminPOTemplateDetails, EditByOwnersOrAdmins):
-    permission = 'launchpad.Edit'
-    usedfor = IPOTemplate
-
-    def checkAuthenticated(self, user):
-        """Allow anyone with admin rights; owners, product owners and
-        distribution owners; and for distros, translation group owners.
-        """
-        if (self.obj.productseries is not None and
-            user.inTeam(self.obj.productseries.product.owner)):
-            # The user is the owner of the product.
-            return True
-
-        return (
-            AdminPOTemplateDetails.checkAuthenticated(self, user) or
-            EditByOwnersOrAdmins.checkAuthenticated(self, user))
+            return self.forwardCheckAuthenticated(
+                user, template.productseries)
 
 
 class AddPOTemplate(OnlyRosettaExpertsAndAdmins):

=== modified file 'lib/lp/translations/browser/configure.zcml'
--- lib/lp/translations/browser/configure.zcml	2011-08-19 15:01:48 +0000
+++ lib/lp/translations/browser/configure.zcml	2011-08-23 11:39:41 +0000
@@ -408,14 +408,14 @@
             name="+edit"
             for="lp.translations.interfaces.potemplate.IPOTemplate"
             class="lp.translations.browser.potemplate.POTemplateEditView"
-            permission="launchpad.Edit"
+            permission="launchpad.TranslationsAdmin"
             template="../../app/templates/generic-edit.pt"
             layer="lp.translations.publisher.TranslationsLayer"/>
         <browser:page
             name="+admin"
             for="lp.translations.interfaces.potemplate.IPOTemplate"
             class="lp.translations.browser.potemplate.POTemplateAdminView"
-            permission="launchpad.TranslationsAdmin"
+            permission="launchpad.Admin"
             template="../../app/templates/generic-edit.pt"
             layer="lp.translations.publisher.TranslationsLayer"/>
         <browser:page

=== modified file 'lib/lp/translations/browser/potemplate.py'
--- lib/lp/translations/browser/potemplate.py	2011-07-29 17:01:31 +0000
+++ lib/lp/translations/browser/potemplate.py	2011-08-23 11:39:41 +0000
@@ -63,15 +63,16 @@
     ICanonicalUrlData,
     ILaunchBag,
     )
-from canonical.launchpad.webapp.launchpadform import ReturnToReferrerMixin
 from canonical.launchpad.webapp.menu import structured
 from canonical.lazr.utils import smartquote
+from lp.app.browser.launchpadform import ReturnToReferrerMixin
 from lp.app.browser.tales import DateTimeFormatterAPI
 from lp.app.enums import (
     service_uses_launchpad,
     ServiceUsage,
     )
 from lp.app.errors import NotFoundError
+from lp.app.validators.name import valid_name
 from lp.registry.browser.productseries import ProductSeriesFacets
 from lp.registry.browser.sourcepackage import SourcePackageFacets
 from lp.registry.interfaces.productseries import IProductSeries
@@ -320,12 +321,11 @@
         if (by_source_count > self.SHOW_RELATED_TEMPLATES):
             other = by_source_count - self.SHOW_RELATED_TEMPLATES
             if (self.context.distroseries):
-                # XXX adiroiban 2009-12-21 bug=499058: A canonical_url for
-                # SourcePackageName is needed to avoid hardcoding this URL.
-                url = (canonical_url(
-                    self.context.distroseries, rootsite="translations") +
-                    "/+source/" + self.context.sourcepackagename.name +
-                    "/+translations")
+                sourcepackage = self.context.distroseries.getSourcePackage(
+                    self.context.sourcepackagename)
+                url = canonical_url(
+                    sourcepackage, rootsite="translations",
+                    view_name='+translations')
             else:
                 url = canonical_url(
                     self.context.productseries,
@@ -554,13 +554,49 @@
     """View class that lets you edit a POTemplate object."""
 
     schema = IPOTemplate
-    field_names = ['translation_domain', 'description', 'priority',
-        'path', 'owner', 'iscurrent']
     label = 'Edit translation template details'
     page_title = 'Edit details'
     PRIORITY_MIN_VALUE = 0
     PRIORITY_MAX_VALUE = 100000
 
+    @property
+    def field_names(self):
+        field_names = [
+            'name', 'translation_domain', 'description', 'priority',
+            'path', 'iscurrent']
+        if self.context.distroseries:
+            field_names.extend([
+                'sourcepackagename',
+                'languagepack',
+                ])
+        return field_names
+
+    @property
+    def _return_url(self):
+        # We override the ReturnToReferrerMixin _return_url because it might
+        # change when any of the productseries, distroseries,
+        # sourcepackagename or name attributes change, and the basic version
+        # only supports watching changes to a single attribute.
+
+        # The referer header is a hidden input in the form.
+        referrer = self.request.form.get('_return_url')
+        returnChanged = False
+        if referrer is None:
+            # "referer" is misspelled in the HTTP specification.
+            referrer = self.request.getHeader('referer')
+            # If we were looking at the actual template, we want a new
+            # URL constructed.
+            if referrer is not None and '/+pots/' in referrer:
+                returnChanged = True
+
+        if (referrer is not None
+            and not returnChanged
+            and referrer.startswith(self.request.getApplicationURL())
+            and referrer != self.request.getHeader('location')):
+            return referrer
+        else:
+            return canonical_url(self.context)
+
     @action(_('Change'), name='change')
     def change_action(self, action, data):
         context = self.context
@@ -581,7 +617,43 @@
             naked_context = removeSecurityProxy(context)
             naked_context.date_last_updated = datetime.datetime.now(pytz.UTC)
 
+    def _validateTargetAndGetTemplates(self, data):
+        """Return a POTemplateSubset corresponding to the chosen target."""
+        sourcepackagename = data.get('sourcepackagename',
+                                     self.context.sourcepackagename)
+        return getUtility(IPOTemplateSet).getSubset(
+            distroseries=self.context.distroseries,
+            sourcepackagename=sourcepackagename,
+            productseries=self.context.productseries)
+
     def validate(self, data):
+        name = data.get('name', None)
+        if name is None or not valid_name(name):
+            self.setFieldError(
+                'name',
+                'Template name can only start with lowercase letters a-z '
+                'or digits 0-9, and other than those characters, can only '
+                'contain "-", "+" and "." characters.')
+
+        distroseries = data.get('distroseries', self.context.distroseries)
+        sourcepackagename = data.get(
+            'sourcepackagename', self.context.sourcepackagename)
+        productseries = data.get('productseries', None)
+        sourcepackage_changed = (
+            distroseries is not None and
+            (distroseries != self.context.distroseries or
+             sourcepackagename != self.context.sourcepackagename))
+        productseries_changed = (productseries is not None and
+                                 productseries != self.context.productseries)
+        similar_templates = self._validateTargetAndGetTemplates(data)
+        if similar_templates is not None:
+            self.validateName(
+                name, similar_templates, sourcepackage_changed,
+                productseries_changed)
+            self.validateDomain(
+                data.get('translation_domain'), similar_templates,
+                sourcepackage_changed, productseries_changed)
+
         priority = data.get('priority')
         if priority is None:
             return
@@ -592,24 +664,6 @@
                 'priority',
                 'The priority value must be between %s and %s.' % (
                 self.PRIORITY_MIN_VALUE, self.PRIORITY_MAX_VALUE))
-            return
-
-    @property
-    def _return_attribute_name(self):
-        """See 'ReturnToReferrerMixin'."""
-        return "name"
-
-
-class POTemplateAdminView(POTemplateEditView):
-    """View class that lets you admin a POTemplate object."""
-    field_names = [
-        'name', 'translation_domain', 'description', 'header', 'iscurrent',
-        'owner', 'productseries', 'distroseries', 'sourcepackagename',
-        'from_sourcepackagename', 'sourcepackageversion', 'binarypackagename',
-        'languagepack', 'path', 'source_file_format', 'priority',
-        'date_last_updated']
-    label = 'Administer translation template'
-    page_title = "Administer"
 
     def validateName(self, name, similar_templates,
                      sourcepackage_changed, productseries_changed):
@@ -645,8 +699,25 @@
                 self.setFieldError(
                     'translation_domain', "Domain is already in use.")
 
-    def validate(self, data):
-        super(POTemplateAdminView, self).validate(data)
+    @property
+    def _return_attribute_name(self):
+        """See 'ReturnToReferrerMixin'."""
+        return "name"
+
+
+class POTemplateAdminView(POTemplateEditView):
+    """View class that lets you admin a POTemplate object."""
+    field_names = [
+        'name', 'translation_domain', 'description', 'header', 'iscurrent',
+        'owner', 'productseries', 'distroseries', 'sourcepackagename',
+        'from_sourcepackagename', 'sourcepackageversion', 'binarypackagename',
+        'languagepack', 'path', 'source_file_format', 'priority',
+        'date_last_updated']
+    label = 'Administer translation template'
+    page_title = "Administer"
+
+    def _validateTargetAndGetTemplates(self, data):
+        """Return a POTemplateSubset corresponding to the chosen target."""
         distroseries = data.get('distroseries')
         sourcepackagename = data.get('sourcepackagename')
         productseries = data.get('productseries')
@@ -662,27 +733,10 @@
 
         if message is not None:
             self.addError(message)
-            return
-
-        # Validate name and domain; they should be unique within the
-        # template's translation target (productseries or source
-        # package).  Validate against the target selected by the form,
-        # not the template's existing target; the form may change the
-        # template's target as well.
-        similar_templates = getUtility(IPOTemplateSet).getSubset(
+            return None
+        return getUtility(IPOTemplateSet).getSubset(
             distroseries=distroseries, sourcepackagename=sourcepackagename,
             productseries=productseries)
-        sourcepackage_changed = (
-            distroseries != self.context.distroseries or
-            sourcepackagename != self.context.sourcepackagename)
-        productseries_changed = productseries != self.context.productseries
-
-        self.validateName(
-            data.get('name'), similar_templates,
-            sourcepackage_changed, productseries_changed)
-        self.validateDomain(
-            data.get('translation_domain'), similar_templates,
-            sourcepackage_changed, productseries_changed)
 
 
 class POTemplateExportView(BaseExportView):
@@ -872,10 +926,10 @@
             self.distroseries = series
         else:
             self.productseries = series
-        self.can_admin = check_permission(
-            'launchpad.TranslationsAdmin', series)
+        self.can_admin = check_permission('launchpad.Admin', series)
         self.can_edit = (
-            self.can_admin or check_permission('launchpad.Edit', series))
+            self.can_admin or
+            check_permission('launchpad.TranslationsAdmin', series))
 
         self.user_is_logged_in = (self.user is not None)
 

=== modified file 'lib/lp/translations/browser/tests/test_potemplate_views.py'
--- lib/lp/translations/browser/tests/test_potemplate_views.py	2011-06-07 12:59:28 +0000
+++ lib/lp/translations/browser/tests/test_potemplate_views.py	2011-08-23 11:39:41 +0000
@@ -9,10 +9,13 @@
 from canonical.launchpad.webapp.servers import LaunchpadTestRequest
 from canonical.testing.layers import DatabaseFunctionalLayer
 from lp.testing import TestCaseWithFactory
-from lp.translations.browser.potemplate import POTemplateAdminView
-
-
-class TestPOTemplateAdminViewValidation(TestCaseWithFactory):
+from lp.translations.browser.potemplate import (
+    POTemplateAdminView,
+    POTemplateEditView,
+    )
+
+
+class TestPOTemplateEditViewValidation(TestCaseWithFactory):
 
     layer = DatabaseFunctionalLayer
 
@@ -29,6 +32,19 @@
         data.update(**kwargs)
         return data
 
+    def test_detects_invalid_names(self):
+        # A template name must be satisfying the valid_name constraint.
+        invalid_name = 'name!'
+        potemplate = self.factory.makePOTemplate()
+        data = self._makeData(potemplate, name=invalid_name)
+        view = POTemplateEditView(potemplate, LaunchpadTestRequest())
+        view.validate(data)
+        self.assertEqual(
+            [u'Template name can only start with lowercase letters a-z '
+             u'or digits 0-9, and other than those characters, can only '
+             u'contain "-", "+" and "." characters.'],
+            view.errors)
+
     def test_detects_name_clash_on_name_change(self):
         # A template name may not already be used.
         existing_name = self.factory.getUniqueString()
@@ -36,7 +52,7 @@
         series = existing_potemplate.productseries
         potemplate = self.factory.makePOTemplate(productseries=series)
 
-        view = POTemplateAdminView(potemplate, LaunchpadTestRequest())
+        view = POTemplateEditView(potemplate, LaunchpadTestRequest())
         data = self._makeData(potemplate, name=existing_name)
         view.validate(data)
         self.assertEqual([u'Name is already in use.'], view.errors)
@@ -49,11 +65,50 @@
         series = existing_potemplate.productseries
         potemplate = self.factory.makePOTemplate(productseries=series)
 
-        view = POTemplateAdminView(potemplate, LaunchpadTestRequest())
+        view = POTemplateEditView(potemplate, LaunchpadTestRequest())
         data = self._makeData(potemplate, translation_domain=existing_domain)
         view.validate(data)
         self.assertEqual([u'Domain is already in use.'], view.errors)
 
+    def test_detects_name_clash_on_sourcepackage_change(self):
+        # Detect changing to a source package that already has a template of
+        # the same name.
+        sourcepackage = self.factory.makeSourcePackage()
+        existing_potemplate = self.factory.makePOTemplate(
+            sourcepackage=sourcepackage)
+        potemplate = self.factory.makePOTemplate(
+            distroseries=sourcepackage.distroseries,
+            name=existing_potemplate.name)
+
+        view = POTemplateEditView(potemplate, LaunchpadTestRequest())
+        data = self._makeData(
+            potemplate, sourcepackagename=sourcepackage.sourcepackagename)
+        view.validate(data)
+        self.assertEqual(
+            [u'Source package already has a template with that same name.'],
+            view.errors)
+
+    def test_detects_domain_clash_on_sourcepackage_change(self):
+        # Detect changing to a source package that already has a template with
+        # the same translation domain.
+        sourcepackage = self.factory.makeSourcePackage()
+        existing_potemplate = self.factory.makePOTemplate(
+            sourcepackage=sourcepackage)
+        potemplate = self.factory.makePOTemplate(
+            distroseries=sourcepackage.distroseries,
+            translation_domain=existing_potemplate.translation_domain)
+
+        view = POTemplateEditView(potemplate, LaunchpadTestRequest())
+        data = self._makeData(
+            potemplate, sourcepackagename=sourcepackage.sourcepackagename)
+        view.validate(data)
+        self.assertEqual(
+            [u'Source package already has a template with that same domain.'],
+            view.errors)
+
+
+class TestPOTemplateAdminViewValidation(TestPOTemplateEditViewValidation):
+
     def test_detects_name_clash_on_productseries_change(self):
         # Detect changing to a productseries that already has a template of
         # the same name.
@@ -86,42 +141,6 @@
             [u'Series already has a template with that same domain.'],
             view.errors)
 
-    def test_detects_name_clash_on_sourcepackage_change(self):
-        # Detect changing to a source package that already has a template of
-        # the same name.
-        sourcepackage = self.factory.makeSourcePackage()
-        existing_potemplate = self.factory.makePOTemplate(
-            sourcepackage=sourcepackage)
-        potemplate = self.factory.makePOTemplate(
-            distroseries=sourcepackage.distroseries,
-            name=existing_potemplate.name)
-
-        view = POTemplateAdminView(potemplate, LaunchpadTestRequest())
-        data = self._makeData(
-            potemplate, sourcepackagename=sourcepackage.sourcepackagename)
-        view.validate(data)
-        self.assertEqual(
-            [u'Source package already has a template with that same name.'],
-            view.errors)
-
-    def test_detects_domain_clash_on_sourcepackage_change(self):
-        # Detect changing to a productseries that already has a template with
-        # the same translation domain.
-        sourcepackage = self.factory.makeSourcePackage()
-        existing_potemplate = self.factory.makePOTemplate(
-            sourcepackage=sourcepackage)
-        potemplate = self.factory.makePOTemplate(
-            distroseries=sourcepackage.distroseries,
-            translation_domain=existing_potemplate.translation_domain)
-
-        view = POTemplateAdminView(potemplate, LaunchpadTestRequest())
-        data = self._makeData(
-            potemplate, sourcepackagename=sourcepackage.sourcepackagename)
-        view.validate(data)
-        self.assertEqual(
-            [u'Source package already has a template with that same domain.'],
-            view.errors)
-
     def test_detects_no_sourcepackage_or_productseries(self):
         # Detect if no source package or productseries was selected.
         potemplate = self.factory.makePOTemplate()

=== modified file 'lib/lp/translations/configure.zcml'
--- lib/lp/translations/configure.zcml	2011-07-30 14:21:23 +0000
+++ lib/lp/translations/configure.zcml	2011-08-23 11:39:41 +0000
@@ -431,15 +431,15 @@
             <allow
                 interface="lp.translations.interfaces.potemplate.IPOTemplate"/>
             <require
-                permission="launchpad.Edit"
-                set_attributes="
-                    owner priority description translation_domain path
-                    iscurrent"/>
-            <require
                 permission="launchpad.TranslationsAdmin"
                 set_attributes="
-                    name productseries distroseries sourcepackagename
-                    sourcepackageversion binarypackagename languagepack
+                    name priority description translation_domain path
+                    iscurrent languagepack sourcepackagename"/>
+            <require
+                permission="launchpad.Admin"
+                set_attributes="
+                    owner productseries distroseries
+                    sourcepackageversion binarypackagename
                     source_file_format source_file date_last_updated
                     from_sourcepackagename header"/>
         </class>

=== modified file 'lib/lp/translations/interfaces/potemplate.py'
--- lib/lp/translations/interfaces/potemplate.py	2011-07-28 12:59:26 +0000
+++ lib/lp/translations/interfaces/potemplate.py	2011-08-23 11:39:41 +0000
@@ -33,6 +33,7 @@
 from canonical.launchpad import _
 from canonical.launchpad.interfaces.librarian import ILibraryFileAlias
 from lp.app.errors import NotFoundError
+from lp.app.validators.name import valid_name
 from lp.registry.interfaces.distribution import IDistribution
 from lp.registry.interfaces.sourcepackage import ISourcePackage
 from lp.registry.interfaces.sourcepackagename import ISourcePackageName
@@ -112,7 +113,7 @@
             "unique name in its package. It's important to get this "
             "correct, because Launchpad will recommend alternative "
             "translations based on the name."),
-        required=True))
+        constraint=valid_name, required=True))
 
     translation_domain = exported(TextLine(
         title=_("Translation domain"),

=== modified file 'lib/lp/translations/templates/potemplate-index.pt'
--- lib/lp/translations/templates/potemplate-index.pt	2011-03-08 09:59:36 +0000
+++ lib/lp/translations/templates/potemplate-index.pt	2011-08-23 11:39:41 +0000
@@ -28,10 +28,6 @@
       <strong>Owner:</strong>
       <a tal:replace="structure context/owner/fmt:link">owner name</a>
     </p>
-    <div>
-      <a href="+edit" class="edit sprite"
-         tal:condition="context/required:launchpad.Edit">Change details</a>
-    </div>
   </div>
 
   <div class="yui-g">
@@ -119,11 +115,16 @@
           translation tarballs.
         </p>
         <div tal:condition="context/required:launchpad.TranslationsAdmin">
-          <a tal:attributes="href
-                             context/menu:navigation/administer/url"
+          <a href="+edit" class="edit sprite"
+             tal:condition="context/required:launchpad.TranslationsAdmin">
+            Change details</a>
+          <tal:admin condition="context/required:launchpad.Admin">
+            — <a tal:attributes="href
+                               context/menu:navigation/administer/url"
              class="edit sprite">
-            Administer this template
-          </a>
+              Administer this template
+            </a>
+          </tal:admin>
         </div>
       </div>
     </div>


Follow ups