launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #00861
[Merge] lp:~jtv/launchpad/bug-618393 into lp:launchpad/devel
Jeroen T. Vermeulen has proposed merging lp:~jtv/launchpad/bug-618393 into lp:launchpad/devel.
Requested reviews:
Launchpad code reviewers (launchpad-reviewers): code
= Bug 618393 =
Working on some timeouts on the TranslationGroup pages.
I looked at one oops in detail. A narrow majority of the time was taken up by database queries. So I mostly attacked those before resorting to python profiling.
Here I exploit several optimization opportunities:
* Prejoin icon data for persons, persons, etc. (20% of queries).
* Prejoin project license information (15% of queries).
* Cache whether user has Edit privileges in the view.
* Query translators once; avoid redundant is_empty check.
* Query for projects/project groups/distributions on the slave store.
The most interesting work is on prefetching project license information. What makes this necessary is the link formatter for Product: it queries for licenses in order to flag projects whose licenses have not yet been set.
These are not queries one can avoid through Storm caching. Luckily there is a class that lets me get around that: ProductWithLicenses. It's a wrapper for Product that you can pre-seed with the project's licenses, which you can obtain from the same query that gave you the Product itself. This helper is still a bit rough around the edges; I added a class method to compose the necessary join in Storm and took some of the data massage from the call site into the constructor.
You'll notice that I had to change the license_status property in ProductWithLicenses. The reason is a subtle mismatch in access permissions between Product and ProductWithLicenses: license_status is publicly visible either way, but it is implemented in terms of license_approved and license_reviewed which require special privileges. The Product implementation got around this trivially: license_status is public, and once you're in the property code, you're past the security proxy and can access self.license_approved etc. without problems. But the ProductWithLicenses implementation is an outsider: when it evaluates self.license_approved it delegates to self.product, which is still shielded by a security proxy. Thus ProductWithLicenses de facto required special privileges. I bypassed the proxy for this, exposing license_status but not license_approved—exactly as in Product.
To test,
{{{
./bin/test -vvc lp.translations -t translationgroup
./bin/test -vvc lp.registry -t productwithlicenses -t pillar.txt
}}}
To Q/A, go to
https://translations.edge.launchpad.net/+groups/ubuntu-translators/+index
It should not time out. Also, the page should issue at most 600 or so queries, as opposed to the current 1,000.
No lint,
Jeroen
--
https://code.launchpad.net/~jtv/launchpad/bug-618393/+merge/34515
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jtv/launchpad/bug-618393 into lp:launchpad/devel.
=== modified file 'lib/lp/registry/model/pillar.py'
--- lib/lp/registry/model/pillar.py 2010-08-20 20:31:18 +0000
+++ lib/lp/registry/model/pillar.py 2010-09-03 06:46:46 +0000
@@ -17,11 +17,7 @@
ForeignKey,
StringCol,
)
-from storm.expr import (
- LeftJoin,
- NamedFunc,
- Select,
- )
+from storm.expr import LeftJoin
from storm.info import ClassAlias
from storm.locals import SQL
from storm.store import Store
@@ -51,11 +47,9 @@
from lp.registry.interfaces.product import (
IProduct,
IProductSet,
- License,
)
from lp.registry.interfaces.projectgroup import IProjectGroupSet
from lp.registry.model.featuredproject import FeaturedProject
-from lp.registry.model.productlicense import ProductLicense
__all__ = [
@@ -197,23 +191,15 @@
def search(self, text, limit):
"""See `IPillarSet`."""
- # These classes are imported in this method to prevent an import loop.
- from lp.registry.model.product import (
- Product, ProductWithLicenses)
+ # Avoid circular import.
+ from lp.registry.model.product import ProductWithLicenses
if limit is None:
limit = config.launchpad.default_batch_size
- class Array(NamedFunc):
- """Storm representation of the array() PostgreSQL function."""
- name = 'array'
-
# Pull out the licenses as a subselect which is converted
# into a PostgreSQL array so that multiple licenses per product
# can be retrieved in a single row for each product.
- extra_column = Array(
- Select(columns=[ProductLicense.license],
- where=(ProductLicense.product == Product.id),
- tables=[ProductLicense]))
+ extra_column = ProductWithLicenses.composeLicensesColumn()
result = self.build_search_query(text, [extra_column])
# If the search text matches the name or title of the
@@ -244,18 +230,12 @@
stacklevel=2)
pillars = []
# Prefill pillar.product.licenses.
- for pillar_name, other, product, project, distro, license_ids in (
+ for pillar_name, other, product, project, distro, licenses in (
result[:limit]):
pillar = pillar_name.pillar
if IProduct.providedBy(pillar):
- licenses = [
- License.items[license_id]
- for license_id in license_ids]
- product_with_licenses = ProductWithLicenses(
- pillar, tuple(sorted(licenses)))
- pillars.append(product_with_licenses)
- else:
- pillars.append(pillar)
+ pillar = ProductWithLicenses(pillar, licenses)
+ pillars.append(pillar)
return pillars
def add_featured_project(self, project):
=== modified file 'lib/lp/registry/model/product.py'
--- lib/lp/registry/model/product.py 2010-09-03 03:12:39 +0000
+++ lib/lp/registry/model/product.py 2010-09-03 06:46:46 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2010 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
# pylint: disable-msg=E0611,W0212
@@ -25,11 +25,13 @@
SQLObjectNotFound,
StringCol,
)
+from storm.expr import NamedFunc
from storm.locals import (
And,
Desc,
Int,
Join,
+ Select,
SQL,
Store,
Unicode,
@@ -85,7 +87,6 @@
SpecificationDefinitionStatus,
SpecificationFilter,
SpecificationImplementationStatus,
- SpecificationSort,
)
from lp.blueprints.model.specification import (
HasSpecificationsMixin,
@@ -201,14 +202,25 @@
return LicenseStatus.OPEN_SOURCE
+class Array(NamedFunc):
+ """Implements the postgres "array" function in Storm."""
+ name = 'array'
+
+
class ProductWithLicenses:
"""Caches `Product.licenses`."""
delegates(IProduct, 'product')
- def __init__(self, product, licenses):
+ def __init__(self, product, license_ids):
+ """Initialize a `ProductWithLicenses`.
+
+ :param product: the `Product` to wrap.
+ :param license_ids: a sequence of numeric `License` ids.
+ """
self.product = product
- self._licenses = licenses
+ self._licenses = tuple([
+ License.items[id] for id in sorted(license_ids)])
@property
def licenses(self):
@@ -223,8 +235,40 @@
`Product.licenses`, which is not cached, instead of
`ProductWithLicenses.licenses`, which is cached.
"""
+ naked_product = removeSecurityProxy(self.product)
return get_license_status(
- self.license_approved, self.license_reviewed, self.licenses)
+ naked_product.license_approved, naked_product.license_reviewed,
+ self.licenses)
+
+ @classmethod
+ def composeLicensesColumn(cls, for_class=None):
+ """Compose a Storm column specification for licenses.
+
+ Use this to render a list of `Product` linkes without querying
+ licenses for each one individually.
+
+ It lets you prefetch the licensing information in the same
+ query that fetches a `Product`. Just add the column spec
+ returned by this function to the query, and pass it to the
+ `ProductWithLicenses` constructor:
+
+ license_column = ProductWithLicenses.composeLicensesColumn()
+ products_with_licenses = [
+ ProductWithLicenses(product, licenses)
+ for product, licenses in store.find(Product, license_column)
+ ]
+
+ :param for_class: Class to find licenses for. Defaults to
+ `Product`, but could also be a Storm `ClassAlias`.
+ """
+ if for_class is None:
+ for_class = Product
+
+ return Array(
+ Select(
+ columns=[ProductLicense.license],
+ where=(ProductLicense.product == for_class.id),
+ tables=[ProductLicense]))
class Product(SQLBase, BugTargetBase, MakesAnnouncements,
=== added file 'lib/lp/registry/tests/test_productwithlicenses.py'
--- lib/lp/registry/tests/test_productwithlicenses.py 1970-01-01 00:00:00 +0000
+++ lib/lp/registry/tests/test_productwithlicenses.py 2010-09-03 06:46:46 +0000
@@ -0,0 +1,114 @@
+# Copyright 2010 Canonical Ltd. This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Unit tests for `ProductWithLicenses`."""
+
+__metaclass__ = type
+
+from operator import attrgetter
+
+from storm.store import Store
+from zope.interface.verify import verifyObject
+
+from canonical.launchpad.ftests import (
+ ANONYMOUS,
+ login,
+ )
+from canonical.testing import DatabaseFunctionalLayer
+from lp.registry.interfaces.product import (
+ IProduct,
+ License,
+ LicenseStatus,
+ )
+from lp.registry.model.product import (
+ Product,
+ ProductWithLicenses,
+ )
+from lp.testing import TestCaseWithFactory
+
+
+class TestProductWithLicenses(TestCaseWithFactory):
+
+ layer = DatabaseFunctionalLayer
+
+ def test_baseline(self):
+ product = self.factory.makeProduct()
+ product_with_licenses = ProductWithLicenses(product, [])
+ # Log in--a full verification takes Edit privileges.
+ login('foo.bar@xxxxxxxxxxxxx')
+ self.assertTrue(verifyObject(IProduct, product_with_licenses))
+
+ def test_uses_cached_licenses(self):
+ # The ProductWithLicenses' licensing information is based purely
+ # on the cached licenses list. The database is not queried to
+ # determine licensing status.
+ product = self.factory.makeProduct(licenses=[License.BSD])
+ product_with_licenses = ProductWithLicenses(
+ product, [License.OTHER_PROPRIETARY.value])
+ license_status = product_with_licenses.license_status
+ self.assertEqual(LicenseStatus.PROPRIETARY, license_status)
+
+ def test_sorts_licenses(self):
+ # The ProductWithLicenses constructor sorts the Licenses by
+ # numeric value.
+ product = self.factory.makeProduct()
+ licenses = [License.AFFERO, License.BSD, License.MIT]
+
+ # Feed the constructor a list of ids in the wrong order.
+ product_with_licenses = ProductWithLicenses(
+ product,
+ sorted([license.value for license in licenses], reverse=True))
+
+ expected = sorted(licenses, key=attrgetter('value'))
+ self.assertEqual(tuple(expected), product_with_licenses.licenses)
+
+ def test_compose_column_without_licenses_produces_empty(self):
+ # The licenses column that ProductWithLicenses produces for a
+ # product without licenses contains an empty list.
+ product = self.factory.makeProduct(licenses=[])
+ column = ProductWithLicenses.composeLicensesColumn()
+ store = Store.of(product)
+ result = list(store.find((Product, column), Product.id == product.id))
+
+ self.assertEqual([(product, [])], result)
+
+ def test_licenses_column_contains_licensing_info(self):
+ # Feeding the licenses column into the ProductWithLicenses
+ # constructor seeds it with the appropriate licenses.
+ product = self.factory.makeProduct(
+ licenses=[License.OTHER_PROPRIETARY])
+ column = ProductWithLicenses.composeLicensesColumn()
+ store = Store.of(product)
+ row = store.find((Product, column), Product.id == product.id).one()
+
+ product_with_licenses = ProductWithLicenses(*row)
+ licenses = product_with_licenses.licenses
+ license_status = product_with_licenses.license_status
+ self.assertEqual((License.OTHER_PROPRIETARY, ), licenses)
+ self.assertEqual(LicenseStatus.PROPRIETARY, license_status)
+
+ def test_licenses_column_aggregates(self):
+ # Adding a licensing column for a product with multiple licenses
+ # still finds a single product, not one per license.
+ licenses = [License.AFFERO, License.GNU_GPL_V3]
+ product = self.factory.makeProduct(licenses=licenses)
+ column = ProductWithLicenses.composeLicensesColumn()
+ store = Store.of(product)
+ result = list(store.find((Product, column), Product.id == product.id))
+
+ self.assertEqual(1, len(result))
+ found_product, found_licenses = result[0]
+ self.assertEqual(product, found_product)
+ self.assertEqual(len(licenses), len(found_licenses))
+
+ def test_license_status_is_public(self):
+ # The license_status attribute can be read by anyone, on
+ # ProductWithLicenses as on Product.
+ product = self.factory.makeProduct(licenses=[License.BSD])
+ product_with_licenses = ProductWithLicenses(
+ product, [License.BSD.value])
+ login(ANONYMOUS)
+ self.assertEqual(
+ LicenseStatus.OPEN_SOURCE, product.license_status)
+ self.assertEqual(
+ LicenseStatus.OPEN_SOURCE, product_with_licenses.license_status)
=== modified file 'lib/lp/translations/browser/tests/test_translationgroup.py'
--- lib/lp/translations/browser/tests/test_translationgroup.py 2010-08-20 20:31:18 +0000
+++ lib/lp/translations/browser/tests/test_translationgroup.py 2010-09-03 06:46:46 +0000
@@ -7,6 +7,7 @@
import unittest
+import transaction
from zope.component import getUtility
from canonical.launchpad.webapp.servers import LaunchpadTestRequest
@@ -40,6 +41,7 @@
# translator_list composes dicts using _makeTranslatorDict.
group = self._makeGroup()
tr_translator = self.factory.makeTranslator('tr', group)
+ transaction.commit()
view = self._makeView(group)
translator_dict = view._makeTranslatorDict(
tr_translator, tr_translator.language, tr_translator.translator)
=== modified file 'lib/lp/translations/browser/translationgroup.py'
--- lib/lp/translations/browser/translationgroup.py 2010-08-20 20:31:18 +0000
+++ lib/lp/translations/browser/translationgroup.py 2010-09-03 06:46:46 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2010 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
"""Browser code for translation groups."""
@@ -26,6 +26,7 @@
LaunchpadFormView,
LaunchpadView,
)
+from canonical.launchpad.webapp.authorization import check_permission
from canonical.launchpad.webapp.breadcrumb import Breadcrumb
from lp.app.errors import NotFoundError
from lp.registry.browser.objectreassignment import ObjectReassignmentView
@@ -67,6 +68,7 @@
self.context = context
self.request = request
self.translation_groups = getUtility(ITranslationGroupSet)
+ self.user_can_edit = check_permission('launchpad.Edit', self.context)
@property
def label(self):
@@ -96,8 +98,7 @@
"""List of dicts describing the translation teams."""
return [
self._makeTranslatorDict(*data)
- for data in self.context.fetchTranslatorData()
- ]
+ for data in self.context.fetchTranslatorData()]
class TranslationGroupAddTranslatorView(LaunchpadFormView):
=== modified file 'lib/lp/translations/doc/translationgroup.txt'
--- lib/lp/translations/doc/translationgroup.txt 2010-02-22 09:06:38 +0000
+++ lib/lp/translations/doc/translationgroup.txt 2010-09-03 06:46:46 +0000
@@ -1,4 +1,5 @@
-= Translation Groups =
+Translation Groups
+==================
>>> from zope.component import getUtility
@@ -63,12 +64,12 @@
No Privileges Person isn't allowed to translate into Welsh.
- >>> series = evolution.getSeries('trunk')
- >>> subset = potemplate_set.getSubset(productseries=series)
- >>> potemplate = subset['evolution-2.2']
- >>> pofile = potemplate.newPOFile('cy')
- >>> pofile.canEditTranslations(no_priv)
- False
+ >>> series = evolution.getSeries('trunk')
+ >>> subset = potemplate_set.getSubset(productseries=series)
+ >>> potemplate = subset['evolution-2.2']
+ >>> pofile = potemplate.newPOFile('cy')
+ >>> pofile.canEditTranslations(no_priv)
+ False
Let's add him to the group.
@@ -176,6 +177,7 @@
>>> de_translator = factory.makeTranslator('de', group, person=de_team)
>>> nl_translator = factory.makeTranslator('nl', group, person=nl_team)
>>> la_translator = factory.makeTranslator('la', group, person=la_team)
+ >>> transaction.commit()
The method returns tuples of respectively a Translator ("translation
group membership entry"), its language, and the actual team or person
=== modified file 'lib/lp/translations/interfaces/translationgroup.py'
--- lib/lp/translations/interfaces/translationgroup.py 2010-08-20 20:31:18 +0000
+++ lib/lp/translations/interfaces/translationgroup.py 2010-09-03 06:46:46 +0000
@@ -186,8 +186,34 @@
def fetchTranslatorData():
"""Fetch translators and related data.
- :return: A tuple (`Translator`, `Language`, `Person`), ordered
- by language name in English.
+ Prefetches display-related properties.
+
+ :return: A result set of (`Translator`, `Language`, `Person`),
+ ordered by language name in English.
+ """
+
+ def fetchProjectsForDisplay():
+ """Fetch `Product`s using this group, for display purposes.
+
+ Prefetches display-related properties.
+
+ :return: A result set of `Product`, ordered by display name.
+ """
+
+ def fetchProjectGroupsForDisplay():
+ """Fetch `Project`s using this group, for display purposes.
+
+ Prefetches display-related properties.
+
+ :return: A result set of `Project`, ordered by display name.
+ """
+
+ def fetchDistrosForDisplay():
+ """Fetch `Distribution`s using this group, for display purposes.
+
+ Prefetches display-related properties.
+
+ :return: A result set of `Distribution`, ordered by display name.
"""
=== modified file 'lib/lp/translations/model/translationgroup.py'
--- lib/lp/translations/model/translationgroup.py 2010-08-20 20:31:18 +0000
+++ lib/lp/translations/model/translationgroup.py 2010-09-03 06:46:46 +0000
@@ -1,4 +1,4 @@
-# Copyright 2009 Canonical Ltd. This software is licensed under the
+# Copyright 2009-2010 Canonical Ltd. This software is licensed under the
# GNU Affero General Public License version 3 (see the file LICENSE).
# pylint: disable-msg=E0611,W0212
@@ -6,7 +6,7 @@
__metaclass__ = type
__all__ = [
'TranslationGroup',
- 'TranslationGroupSet'
+ 'TranslationGroupSet',
]
from sqlobject import (
@@ -16,7 +16,10 @@
SQLRelatedJoin,
StringCol,
)
-from storm.expr import Join
+from storm.expr import (
+ Join,
+ LeftJoin,
+ )
from storm.store import Store
from zope.component import getUtility
from zope.interface import implements
@@ -24,17 +27,27 @@
from canonical.database.constants import DEFAULT
from canonical.database.datetimecol import UtcDateTimeCol
from canonical.database.sqlbase import SQLBase
+from canonical.launchpad.database.librarian import (
+ LibraryFileAlias,
+ LibraryFileContent,
+ )
from canonical.launchpad.webapp.interfaces import (
DEFAULT_FLAVOR,
IStoreSelector,
MAIN_STORE,
+ SLAVE_FLAVOR,
)
from lp.app.errors import NotFoundError
from lp.registry.interfaces.person import validate_public_person
+from lp.registry.model.distribution import Distribution
from lp.registry.model.person import Person
-from lp.registry.model.product import Product
+from lp.registry.model.product import (
+ Product,
+ ProductWithLicenses,
+ )
from lp.registry.model.projectgroup import ProjectGroup
from lp.registry.model.teammembership import TeamParticipation
+from lp.services.database.prejoin import prejoin
from lp.services.worlddata.model.language import Language
from lp.translations.interfaces.translationgroup import (
ITranslationGroup,
@@ -77,10 +90,10 @@
Translator.translationgroup == self,
Translator.languageID == Language.id,
Language.code == language_code)
-
+
translator = query.one()
if translator is None:
- raise NotFoundError, language_code
+ raise NotFoundError(language_code)
return translator
@@ -141,14 +154,106 @@
return 0
def fetchTranslatorData(self):
- """See ITranslationGroup."""
+ """See `ITranslationGroup`."""
store = getUtility(IStoreSelector).get(MAIN_STORE, DEFAULT_FLAVOR)
- translator_data = store.find(
- (Translator, Language, Person),
+ # Fetch Translator, Language, and Person; but also prefetch the
+ # icon information.
+ using = [
+ Translator,
+ Language,
+ Person,
+ LeftJoin(LibraryFileAlias, LibraryFileAlias.id == Person.iconID),
+ LeftJoin(
+ LibraryFileContent,
+ LibraryFileContent.id == LibraryFileAlias.contentID),
+ ]
+ tables = (
+ Translator,
+ Language,
+ Person,
+ LibraryFileAlias,
+ LibraryFileContent,
+ )
+ translator_data = store.using(*using).find(
+ tables,
Translator.translationgroup == self,
Language.id == Translator.languageID,
Person.id == Translator.translatorID)
- return translator_data.order_by(Language.englishname)
+
+ return prejoin(
+ translator_data.order_by(Language.englishname),
+ slice(0, 3))
+
+ def fetchProjectsForDisplay(self):
+ """See `ITranslationGroup`."""
+ store = getUtility(IStoreSelector).get(MAIN_STORE, SLAVE_FLAVOR)
+ using = [
+ Product,
+ LeftJoin(LibraryFileAlias, LibraryFileAlias.id == Product.iconID),
+ LeftJoin(
+ LibraryFileContent,
+ LibraryFileContent.id == LibraryFileAlias.contentID),
+ ]
+ columns = (
+ Product,
+ ProductWithLicenses.composeLicensesColumn(),
+ LibraryFileAlias,
+ LibraryFileContent,
+ )
+ product_data = store.using(*using).find(
+ columns,
+ Product.translationgroupID == self.id, Product.active == True)
+ product_data = product_data.order_by(Product.displayname)
+
+ return [
+ ProductWithLicenses(product, tuple(licenses))
+ for product, licenses, icon_alias, icon_content in product_data]
+
+ def fetchProjectGroupsForDisplay(self):
+ """See `ITranslationGroup`."""
+ store = getUtility(IStoreSelector).get(MAIN_STORE, SLAVE_FLAVOR)
+ using = [
+ ProjectGroup,
+ LeftJoin(
+ LibraryFileAlias, LibraryFileAlias.id == ProjectGroup.iconID),
+ LeftJoin(
+ LibraryFileContent,
+ LibraryFileContent.id == LibraryFileAlias.contentID),
+ ]
+ tables = (
+ ProjectGroup,
+ LibraryFileAlias,
+ LibraryFileContent,
+ )
+ product_data = store.using(*using).find(
+ tables,
+ ProjectGroup.translationgroupID == self.id,
+ ProjectGroup.active == True)
+
+ return prejoin(
+ product_data.order_by(ProjectGroup.displayname), slice(0, 1))
+
+ def fetchDistrosForDisplay(self):
+ """See `ITranslationGroup`."""
+ store = getUtility(IStoreSelector).get(MAIN_STORE, SLAVE_FLAVOR)
+ using = [
+ Distribution,
+ LeftJoin(
+ LibraryFileAlias, LibraryFileAlias.id == Distribution.iconID),
+ LeftJoin(
+ LibraryFileContent,
+ LibraryFileContent.id == LibraryFileAlias.contentID),
+ ]
+ tables = (
+ Distribution,
+ LibraryFileAlias,
+ LibraryFileContent,
+ )
+ product_data = store.using(*using).find(
+ tables, Distribution.translationgroupID == self.id)
+
+ return prejoin(
+ product_data.order_by(Distribution.displayname), slice(0, 1))
class TranslationGroupSet:
@@ -174,7 +279,7 @@
try:
return TranslationGroup.byName(name)
except SQLObjectNotFound:
- raise NotFoundError, name
+ raise NotFoundError(name)
def new(self, name, title, summary, translation_guide_url, owner):
"""See ITranslationGroupSet."""
@@ -193,7 +298,7 @@
Join(Translator,
Translator.translationgroupID == TranslationGroup.id),
Join(TeamParticipation,
- TeamParticipation.teamID == Translator.translatorID)
+ TeamParticipation.teamID == Translator.translatorID),
]
result = store.using(*origin).find(
TranslationGroup, TeamParticipation.person == person)
@@ -203,4 +308,3 @@
def getGroupsCount(self):
"""See ITranslationGroupSet."""
return TranslationGroup.select().count()
-
=== modified file 'lib/lp/translations/templates/translationgroup-index.pt'
--- lib/lp/translations/templates/translationgroup-index.pt 2010-02-22 09:06:38 +0000
+++ lib/lp/translations/templates/translationgroup-index.pt 2010-09-03 06:46:46 +0000
@@ -13,7 +13,7 @@
<a tal:content="context/owner/fmt:displayname"
tal:attributes="href context/owner/fmt:url"
class="link" />
- <a tal:condition="context/required:launchpad.Edit"
+ <a tal:condition="view/user_can_edit"
href="+reassign" title="Change administrator"
class="edit sprite" id="link-reassign"></a>
</p>
@@ -32,7 +32,7 @@
translations in this translation group.</em>
</p>
- <div tal:condition="context/required:launchpad.Edit">
+ <div tal:condition="view/user_can_edit">
<a href="+edit" class="edit sprite">Change details</a>
</div>
@@ -40,7 +40,8 @@
<div id="translation-teams-listing">
<h2><a name="teams"></a>Translation teams</h2>
- <tal:translators condition="context/translators">
+ <tal:translator-list define="translator_list view/translator_list">
+ <tal:translators condition="translator_list">
<table class="sortable listing" id="group-members">
<thead>
<tr>
@@ -48,11 +49,11 @@
<th>Team/Supervisor</th>
<th>Guidelines</th>
<th>Appointed</th>
- <th tal:condition="context/required:launchpad.Edit"></th>
+ <th tal:condition="view/user_can_edit"></th>
</tr>
</thead>
<tbody>
- <tr tal:repeat="translator view/translator_list">
+ <tr tal:repeat="translator translator_list">
<td class="translator-language">
<a tal:attributes="href translator/language/fmt:url"
tal:content="translator/language/displayname">
@@ -82,7 +83,7 @@
none
</span>
<tal:notadmin
- condition="not:context/required:launchpad.Edit">
+ condition="not:view/user_can_edit">
<a tal:condition="
translator/context/required:launchpad.Edit"
tal:attributes="href string:${translator/code}/+edit"
@@ -96,7 +97,7 @@
2007-09-17
</span>
</td>
- <td tal:condition="context/required:launchpad.Edit">
+ <td tal:condition="view/user_can_edit">
<a tal:attributes="
href translator/code;
id string:edit-${translator/code}-translator"
@@ -110,22 +111,22 @@
</tbody>
</table>
</tal:translators>
- <tal:no-translators condition="not: context/translators">
+ <tal:no-translators condition="not: translator_list">
No translation teams or supervisors have been appointed in
this group yet.
</tal:no-translators>
<div style="margin-top:1em; margin-bottom: 2em;">
- <a tal:condition="context/required:launchpad.Edit"
+ <a tal:condition="view/user_can_edit"
href="+appoint" class="add sprite">Appoint a new translation
team</a>
</div>
- </div><!-- id="translations-team-listing" -->
-
- <div class="section">
- <a name="projects"></a>
- <div tal:replace="structure context/@@+portlet-projects" />
- </div>
-
+ </tal:translator-list>
+ </div><!-- id="translations-team-listing" -->
+
+ <div class="section">
+ <a name="projects"></a>
+ <div tal:replace="structure context/@@+portlet-projects" />
+ </div>
</div><!-- main -->
</body>
=== modified file 'lib/lp/translations/templates/translationgroup-portlet-projects.pt'
--- lib/lp/translations/templates/translationgroup-portlet-projects.pt 2010-04-19 08:11:52 +0000
+++ lib/lp/translations/templates/translationgroup-portlet-projects.pt 2010-09-03 06:46:46 +0000
@@ -13,33 +13,34 @@
<a href="#teams">translation teams</a>.
</p>
- <div id="related-projects">
- <div tal:condition="context/distributions" style="margin-top:1em;">
+ <div id="related-projects"
+ tal:define="
+ distributions context/fetchDistrosForDisplay;
+ projectgroups context/fetchProjectGroupsForDisplay;
+ projects context/fetchProjectsForDisplay">
+ <div tal:condition="distributions" style="margin-top:1em;">
<h3 style="display: inline;">Distributions:</h3>
- <tal:distribution
- repeat="distribution context/distributions">
+ <tal:distribution repeat="distribution distributions">
<a href="#" tal:replace="structure distribution/fmt:link">Ubuntu
</a><tal:comma condition="not:repeat/distribution/end">, </tal:comma>
</tal:distribution>
</div>
- <div tal:condition="context/projects" style="margin-top:1em;">
+ <div tal:condition="projectgroups" style="margin-top:1em;">
<h3 style="display: inline;">Project groups:</h3>
- <tal:project
- repeat="projectgroup context/projects">
+ <tal:projectgroup repeat="projectgroup projectgroups">
<a href="#" tal:replace="structure projectgroup/fmt:link">GNOME
</a><tal:comma condition="not:repeat/projectgroup/end">, </tal:comma>
+ </tal:projectgroup>
+ </div>
+
+ <div tal:condition="projects" style="margin-top:1em;">
+ <h3 style="display: inline;">Projects:</h3>
+ <tal:project repeat="project projects">
+ <a href="#" tal:replace="structure project/fmt:link">Firefox
+ </a><tal:comma condition="not:repeat/project/end">, </tal:comma>
</tal:project>
</div>
- <div tal:condition="context/products" style="margin-top:1em;">
- <h3 style="display: inline;">Projects:</h3>
- <tal:product
- repeat="product context/products">
- <a href="#" tal:replace="structure product/fmt:link">Firefox
- </a><tal:comma condition="not:repeat/product/end">, </tal:comma>
- </tal:product>
- </div>
-
</div>
</tal:root>