← Back to team overview

launchpad-reviewers team mailing list archive

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

 

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

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


= TranslationTemplatesCollection test =

This branch provides intermediate testing for TranslationTemplatesCollection, layered between the existing tests for Collection and for the one class implemented in terms of collections, HasTranslationTemplatesMixin.  A silly typo in the TranslationTemplatesCollection revealed a gap in test coverage, which was a good excuse for both fixing the bug and testing the class separately.  I should have done it from the start.

This also introduces another piece of convenience for Collection: a concrete collection class based on Collection selects, by default, just the class that it is a collection of.  So calling TranslationTemplatesCollection.select() without arguments is now allowed, and returns a sequence of POTemplate.

There's also some drive-by lint cleanup.  However I left some pre-existing ones in place because what "make lint" is complaining about looks to me an awful lot like our preferred syntax:

    list_comprehension = [
        frobnicate(item)
        for item in items
        if effable(item)
        ]


Jeroen
-- 
https://code.launchpad.net/~jtv/launchpad/translationtemplatescollection-test/+merge/30114
Your team Launchpad code reviewers is requested to review the proposed merge of lp:~jtv/launchpad/translationtemplatescollection-test into lp:launchpad/devel.
=== modified file 'lib/lp/registry/model/distroseries.py'
--- lib/lp/registry/model/distroseries.py	2010-06-22 19:42:51 +0000
+++ lib/lp/registry/model/distroseries.py	2010-07-16 14:43:43 +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
@@ -71,7 +71,8 @@
 from canonical.launchpad.database.librarian import LibraryFileAlias
 from lp.translations.model.potemplate import (
     HasTranslationTemplatesMixin,
-    POTemplate)
+    POTemplate,
+    TranslationTemplatesCollection)
 from lp.soyuz.model.publishing import (
     BinaryPackagePublishingHistory, SourcePackagePublishingHistory)
 from lp.soyuz.model.queue import PackageUpload, PackageUploadQueue
@@ -1871,40 +1872,9 @@
     def main_archive(self):
         return self.distribution.main_archive
 
-    def getTranslationTemplates(self):
-        """See `IHasTranslationTemplates`."""
-        result = POTemplate.selectBy(distroseries=self,
-                                     orderBy=['-priority', 'name'])
-        return result
-
-    def getCurrentTranslationTemplates(self, just_ids=False):
-        """See `IHasTranslationTemplates`."""
-        store = Store.of(self)
-        if just_ids:
-            looking_for = POTemplate.id
-        else:
-            looking_for = POTemplate
-
-        # Select all current templates for this series, if the Product
-        # actually uses Launchpad Translations.  Otherwise, return an
-        # empty result.
-        result = store.find(looking_for, And(
-            self.distribution.official_rosetta == True,
-            POTemplate.iscurrent == True,
-            POTemplate.distroseries == self))
-        return result.order_by(['-POTemplate.priority', 'POTemplate.name'])
-
-    def getObsoleteTranslationTemplates(self):
-        """See `IHasTranslationTemplates`."""
-        result = POTemplate.select('''
-            distroseries = %s AND
-            distroseries = DistroSeries.id AND
-            DistroSeries.distribution = Distribution.id AND
-            (iscurrent IS FALSE OR Distribution.official_rosetta IS FALSE)
-            ''' % sqlvalues(self),
-            clauseTables = ['DistroSeries', 'Distribution'],
-            orderBy=['-priority', 'name'])
-        return shortlist(result, 300)
+    def getTemplatesCollection(self):
+        """See `IHasTranslationTemplates`."""
+        return TranslationTemplatesCollection().restrictDistroSeries(self)
 
     def getSuite(self, pocket):
         """See `IDistroSeries`."""

=== modified file 'lib/lp/registry/model/productseries.py'
--- lib/lp/registry/model/productseries.py	2010-06-09 08:26:26 +0000
+++ lib/lp/registry/model/productseries.py	2010-07-16 14:43:43 +0000
@@ -40,7 +40,8 @@
 from lp.translations.model.pofile import POFile
 from lp.translations.model.potemplate import (
     HasTranslationTemplatesMixin,
-    POTemplate)
+    POTemplate,
+    TranslationTemplatesCollection)
 from lp.registry.model.productrelease import ProductRelease
 from lp.translations.model.productserieslanguage import (
     ProductSeriesLanguage)
@@ -50,7 +51,6 @@
     HasTranslationImportsMixin)
 from lp.registry.model.structuralsubscription import (
     StructuralSubscriptionTargetMixin)
-from canonical.launchpad.helpers import shortlist
 from canonical.launchpad.interfaces.launchpad import ILaunchpadCelebrities
 from lp.registry.interfaces.packaging import PackagingType
 from lp.blueprints.interfaces.specification import (
@@ -428,46 +428,15 @@
             name=name, dateexpected=dateexpected, summary=summary,
             product=self.product, productseries=self, code_name=code_name)
 
-    def getTranslationTemplates(self):
-        """See `IHasTranslationTemplates`."""
-        result = POTemplate.selectBy(
-            productseries=self, orderBy=['-priority', 'name'])
-        return result
-
-    def getCurrentTranslationTemplates(self, just_ids=False):
-        """See `IHasTranslationTemplates`."""
-        store = Store.of(self)
-        if just_ids:
-            looking_for = POTemplate.id
-        else:
-            looking_for = POTemplate
-
-        # Select all current templates for this series, if the Product
-        # actually uses Launchpad Translations.  Otherwise, return an
-        # empty result.
-        result = store.find(looking_for, And(
-            self.product.official_rosetta == True,
-            POTemplate.iscurrent == True,
-            POTemplate.productseries == self))
-        return result.order_by(['-POTemplate.priority', 'POTemplate.name'])
+    def getTemplatesCollection(self):
+        """See `IHasTranslationTemplates`."""
+        return TranslationTemplatesCollection().restrictProductSeries(self)
 
     @property
     def potemplate_count(self):
         """See `IProductSeries`."""
         return self.getCurrentTranslationTemplates().count()
 
-    def getObsoleteTranslationTemplates(self):
-        """See `IHasTranslationTemplates`."""
-        result = POTemplate.select('''
-            productseries = %s AND
-            productseries = ProductSeries.id AND
-            ProductSeries.product = Product.id AND
-            (iscurrent IS FALSE OR Product.official_rosetta IS FALSE)
-            ''' % sqlvalues(self),
-            orderBy=['-priority', 'name'],
-            clauseTables = ['ProductSeries', 'Product'])
-        return shortlist(result, 300)
-
     @property
     def productserieslanguages(self):
         """See `IProductSeries`."""

=== modified file 'lib/lp/registry/model/sourcepackage.py'
--- lib/lp/registry/model/sourcepackage.py	2010-07-01 19:21:45 +0000
+++ lib/lp/registry/model/sourcepackage.py	2010-07-16 14:43:43 +0000
@@ -39,7 +39,8 @@
 from lp.registry.model.packaging import Packaging
 from lp.translations.model.potemplate import (
     HasTranslationTemplatesMixin,
-    POTemplate)
+    POTemplate,
+    TranslationTemplatesCollection)
 from canonical.launchpad.interfaces.lpstorm import IStore
 from lp.soyuz.model.publishing import (
     SourcePackagePublishingHistory)
@@ -619,39 +620,11 @@
         else:
             return distribution.main_archive
 
-    def getTranslationTemplates(self):
-        """See `IHasTranslationTemplates`."""
-        result = POTemplate.selectBy(
-            distroseries=self.distroseries,
-            sourcepackagename=self.sourcepackagename)
-        return result.orderBy(['-priority', 'name'])
-
-    def getCurrentTranslationTemplates(self, just_ids=False):
-        """See `IHasTranslationTemplates`."""
-        store = Store.of(self.sourcepackagename)
-        if just_ids:
-            looking_for = POTemplate.id
-        else:
-            looking_for = POTemplate
-
-        result = store.find(looking_for, And(
-            POTemplate.iscurrent == True,
-            POTemplate.distroseries == self.distroseries,
-            POTemplate.sourcepackagename == self.sourcepackagename,
-            self.distroseries.distribution.official_rosetta == True))
-        return result.order_by(['-POTemplate.priority', 'POTemplate.name'])
-
-    def getObsoleteTranslationTemplates(self):
-        """See `IHasTranslationTemplates`."""
-        result = POTemplate.select('''
-            distroseries = %s AND
-            sourcepackagename = %s AND
-            distroseries = DistroSeries.id AND
-            DistroSeries.distribution = Distribution.id AND
-            (iscurrent IS FALSE OR Distribution.official_rosetta IS FALSE)
-            ''' % sqlvalues(self.distroseries, self.sourcepackagename),
-            clauseTables = ['DistroSeries', 'Distribution'])
-        return shortlist(result.orderBy(['-priority', 'name']), 300)
+    def getTemplatesCollection(self):
+        """See `IHasTranslationTemplates`."""
+        collection = TranslationTemplatesCollection()
+        collection = collection.restrictDistroSeries(self.distroseries)
+        return collection.restrictSourcePackageName(self.sourcepackagename)
 
     def getBranch(self, pocket):
         """See `ISourcePackage`."""

=== modified file 'lib/lp/registry/templates/distroseries-packaging.pt'
--- lib/lp/registry/templates/distroseries-packaging.pt	2010-06-30 18:31:18 +0000
+++ lib/lp/registry/templates/distroseries-packaging.pt	2010-07-16 14:43:43 +0000
@@ -57,7 +57,7 @@
               <tr tal:define="series packaging/productseries;
                               product series/product;
                               package packaging/sourcepackage;
-                              translations package/getTranslationTemplates;">
+                              translations package/has_translation_templates;">
                 <td>
                   <a class="sprite package-source"
                     tal:attributes="href package/fmt:url"

=== modified file 'lib/lp/registry/templates/sourcepackage-upstream-connections.pt'
--- lib/lp/registry/templates/sourcepackage-upstream-connections.pt	2010-03-02 20:09:08 +0000
+++ lib/lp/registry/templates/sourcepackage-upstream-connections.pt	2010-07-16 14:43:43 +0000
@@ -50,7 +50,7 @@
         <tal:yes-no replace="structure series/branch/image:boolean"/>
       </dd>
       <dd title="Series translations auto import"
-          tal:condition="context/getTranslationTemplates"
+          tal:condition="context/has_translation_templates"
           tal:define="bool not:series/translations_autoimport_mode/enumvalue:NO_IMPORT">
         Translations:
         <tal:yes-no replace="structure bool/image:boolean"/>

=== added file 'lib/lp/services/database/collection.py'
--- lib/lp/services/database/collection.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/database/collection.py	2010-07-16 14:43:43 +0000
@@ -0,0 +1,139 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""A generic collection of database objects."""
+
+__metaclass__ = type
+__all__ = [
+    'Collection',
+    ]
+
+from zope.component import getUtility
+
+from storm.expr import Join, LeftJoin
+
+from canonical.launchpad.webapp.interfaces import (
+    DEFAULT_FLAVOR, IStoreSelector, MAIN_STORE)
+
+
+class Collection(object):
+    """An arbitrary collection of database objects.
+
+    Works as a Storm wrapper: create a collection based on another
+    collection, adding joins and select conditions to taste.
+
+    As in any Storm query, you can select any mix of classes and
+    individual columns or other Storm expressions.
+    """
+
+    # Default table for this collection that will always be included.
+    # Derived collection classes can use this to say what type they are
+    # a collection of.
+    starting_table = None
+
+    def __init__(self, *args, **kwargs):
+        """Construct a collection, possibly based on another one.
+
+        :param base: Optional collection that this collection is based
+            on.  The new collection will inherit its configuration.
+        :param conditions: Optional Storm select conditions, e.g.
+            `MyClass.attribute > 2`.
+        :param classes: A class, or tuple or list of classes, that
+            should go into the "FROM" clause of the new collection.
+            This need not include classes that are already in the
+            base collection, or that are included as outer joins.
+        :param store: Optional: Storm `Store` to use.
+        """
+        starting_tables = []
+
+        if len(args) >= 1 and isinstance(args[0], Collection):
+            # There's a base collection.
+            base = args[0]
+            conditions = args[1:]
+        else:
+            # We're starting a fresh collection.
+            base = None
+            conditions = args
+            if self.starting_table is not None:
+                starting_tables = [self.starting_table]
+
+        self.base = base
+
+        if base is None:
+            base_conditions = (True, )
+            base_tables = []
+        else:
+            self.store = base.store
+            base_conditions = base.conditions
+            base_tables = list(base.tables)
+
+        self.store = kwargs.get('store')
+        if self.store is None:
+            self.store = getUtility(IStoreSelector).get(
+                MAIN_STORE, DEFAULT_FLAVOR)
+
+        self.tables = (
+            starting_tables + base_tables +
+            self._parseTablesArg(kwargs.get('tables', [])))
+
+        self.conditions = base_conditions + conditions
+
+    def refine(self, *args, **kwargs):
+        """Return a copy of self with further restrictions, tables etc."""
+        cls = self.__class__
+        return cls(self, *args, **kwargs)
+
+    def _parseTablesArg(self, tables):
+        """Turn tables argument into a list.
+
+        :param tables: A class, or tuple of classes, or list of classes.
+        :param return: All classes that were passed in, as a list.
+        """
+        if isinstance(tables, tuple):
+            return list(tables)
+        elif isinstance(tables, list):
+            return tables
+        else:
+            return [tables]
+
+    def use(self, store):
+        """Return a copy of this collection that uses the given store."""
+        return self.refine(store=store)
+
+    def joinInner(self, cls, *conditions):
+        """Convenience method: inner-join `cls` into the query.
+
+        This is equivalent to creating a `Collection` based on this one
+        but with `cls` and `conditions` added.
+        """
+        return self.refine(tables=[Join(cls, *conditions)])
+
+    def joinOuter(self, cls, *conditions):
+        """Outer-join `cls` into the query."""
+        return self.refine(tables=[LeftJoin(cls, *conditions)])
+
+    def select(self, *values):
+        """Return a result set containing the requested `values`.
+
+        If no values are requested, this selects the type of object that
+        the Collection is a collection of.
+        """
+        if len(self.tables) == 0:
+            source = self.store
+        else:
+            source = self.store.using(*self.tables)
+
+        if len(values) > 1:
+            # Selecting a tuple of values.  Pass it to Storm unchanged.
+            pass
+        elif len(values) == 1:
+            # One value requested.  Unpack for convenience.
+            values = values[0]
+        else:
+            # Select the starting table by default.
+            assert self.starting_table is not None, (
+                "Collection %s does not define a starting table." %
+                    self.__class__.__name__)
+            values = self.starting_table
+
+        return source.find(values, *self.conditions)

=== added file 'lib/lp/services/database/tests/test_collection.py'
--- lib/lp/services/database/tests/test_collection.py	1970-01-01 00:00:00 +0000
+++ lib/lp/services/database/tests/test_collection.py	2010-07-16 14:43:43 +0000
@@ -0,0 +1,186 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Tests for `Collection`."""
+
+__metaclass__ = type
+
+from storm.locals import Int, Storm
+from zope.component import getUtility
+
+from lp.services.database.collection import Collection
+from lp.testing import TestCaseWithFactory
+from lp.testing.fakemethod import FakeMethod
+from canonical.testing import ZopelessDatabaseLayer
+from canonical.launchpad.webapp.interfaces import (
+    IStoreSelector, MAIN_STORE, MASTER_FLAVOR)
+
+
+def get_store():
+    return getUtility(IStoreSelector).get(MAIN_STORE, MASTER_FLAVOR)
+
+
+class FakeStore:
+    find = FakeMethod(result=[])
+
+    def using(self, *args):
+        return self
+
+
+def make_table(range_start, range_end, table_name=None):
+    """Create a temporary table and a storm class for it."""
+    assert range_start < range_end, "Invalid range."
+    if table_name is None:
+        table_name = "TestTable"
+    get_store().execute("""
+       CREATE TEMP TABLE %s AS
+       SELECT generate_series AS id
+       FROM generate_series(%d, %d)
+       """ % (table_name, range_start, range_end - 1))
+
+    class TestTable(Storm):
+        """A test class/table generated on the fly for testing purposes."""
+        __storm_table__ = table_name
+        id = Int(primary=True)
+
+        def __init__(self, id):
+            self.id = id
+
+        def __eq__(self, other):
+            return self.id == other.id
+
+    return TestTable
+
+
+def get_ids(testtable_objects):
+    """Helper to unpack ids from a sequence of TestTable objects."""
+    return [obj.id for obj in testtable_objects]
+
+
+class CollectionTest(TestCaseWithFactory):
+
+    layer = ZopelessDatabaseLayer
+
+    def test_make_table(self):
+        TestTable = make_table(1, 5)
+        result = get_store().find(TestTable).order_by(TestTable.id)
+        self.assertEqual(range(1, 5), get_ids(result))
+
+    def test_select_one(self):
+        TestTable = make_table(1, 5)
+        collection = Collection(TestTable.id == 1, tables=TestTable)
+        result = collection.select(TestTable)
+        self.assertEqual([1], get_ids(result))
+
+    def test_select_all(self):
+        TestTable = make_table(1, 3)
+        collection = Collection(tables=TestTable)
+        result = collection.select(TestTable)
+        self.assertContentEqual([1, 2], get_ids(result))
+
+    def test_select_condition(self):
+        TestTable = make_table(1, 5)
+        collection = Collection(TestTable.id > 2, tables=TestTable)
+        result = collection.select(TestTable)
+        self.assertContentEqual([3, 4], get_ids(result))
+
+    def test_select_conditions(self):
+        TestTable = make_table(1, 5)
+        collection = Collection(
+            TestTable.id > 2, TestTable.id < 4, tables=TestTable)
+        result = collection.select(TestTable)
+        self.assertContentEqual([3], get_ids(result))
+
+    def test_select_column(self):
+        TestTable = make_table(1, 3)
+        collection = Collection(tables=TestTable)
+        result = collection.select(TestTable.id)
+        self.assertContentEqual([1, 2], list(result))
+
+    def test_copy_collection(self):
+        TestTable = make_table(1, 3)
+        collection = Collection(tables=TestTable)
+        copied_collection = Collection(collection)
+        result = copied_collection.select(TestTable)
+        self.assertContentEqual([1, 2], get_ids(result))
+
+    def test_restrict_collection(self):
+        TestTable = make_table(1, 3)
+        collection = Collection(tables=TestTable)
+        copied_collection = Collection(collection, TestTable.id < 2)
+        result = copied_collection.select(TestTable)
+        self.assertContentEqual([1], get_ids(result))
+
+    def test_add_tables(self):
+        # The list of tables to select from carries across copies.
+        TestTable1 = make_table(1, 2, 'TestTable1')
+        TestTable2 = make_table(2, 3, 'TestTable2')
+        collection = Collection(tables=TestTable1)
+        collection = Collection(collection, tables=TestTable2)
+        result = collection.select(TestTable1.id, TestTable2.id)
+        self.assertEqual([(1, 2)], list(result))
+
+    def test_add_tables_and_conditions(self):
+        TestTable1 = make_table(1, 2, 'TestTable1')
+        TestTable2 = make_table(2, 3, 'TestTable2')
+        collection = Collection(TestTable1.id == 1, tables=TestTable1)
+        collection = Collection(
+            collection, TestTable2.id == 2, tables=TestTable2)
+        result = collection.select(TestTable1.id, TestTable2.id)
+        self.assertEqual([(1, 2)], list(result))
+
+    def test_select_join(self):
+        TestTable1 = make_table(1, 2, 'TestTable1')
+        TestTable2 = make_table(2, 3, 'TestTable2')
+        collection = Collection(tables=(TestTable1, TestTable2))
+        result = collection.select(TestTable1, TestTable2)
+        self.assertEqual(
+            [(TestTable1(id=1), TestTable2(id=2))], list(result))
+
+    def test_select_join_column(self):
+        TestTable1 = make_table(1, 2, 'TestTable1')
+        TestTable2 = make_table(2, 3, 'TestTable2')
+        collection = Collection(tables=(TestTable1, TestTable2))
+        result = collection.select(TestTable1.id, TestTable2.id)
+        self.assertEqual([(1, 2)], list(result))
+
+    def test_select_partial_join(self):
+        TestTable1 = make_table(1, 2, 'TestTable1')
+        TestTable2 = make_table(2, 3, 'TestTable2')
+        collection = Collection(
+            TestTable2.id == TestTable1.id + 1,
+            tables=(TestTable1, TestTable2))
+        result = collection.select(TestTable1.id)
+        self.assertEqual([1], list(result))
+
+    def test_joinInner(self):
+        TestTable1 = make_table(1, 3, 'TestTable1')
+        TestTable2 = make_table(2, 4, 'TestTable2')
+
+        # Add a join table to the collection.
+        collection = Collection(tables=TestTable1).joinInner(
+            TestTable2, TestTable2.id == TestTable1.id)
+        result = collection.select(TestTable1.id, TestTable2.id)
+        self.assertContentEqual([(2, 2)], list(result))
+
+    def test_joinOuter(self):
+        TestTable1 = make_table(1, 3, 'TestTable1')
+        TestTable2 = make_table(2, 4, 'TestTable2')
+
+        # Add an outer-join table to the collection.
+        collection = Collection(tables=TestTable1).joinOuter(
+            TestTable2, TestTable2.id == TestTable1.id)
+        result = collection.select(TestTable1.id, TestTable2.id)
+        self.assertContentEqual([(1, None), (2, 2)], list(result))
+
+    def test_select_store(self):
+        TestTable = make_table(1, 2)
+        collection = Collection(tables=TestTable)
+
+        store = FakeStore()
+        self.assertNotEqual(store, collection.store)
+
+        collection_with_store = collection.use(store)
+        self.assertEqual(store, collection_with_store.store)
+
+        self.assertEqual([], collection_with_store.select(TestTable))

=== added file 'lib/lp/services/doc/collection.txt'
--- lib/lp/services/doc/collection.txt	1970-01-01 00:00:00 +0000
+++ lib/lp/services/doc/collection.txt	2010-07-16 14:43:43 +0000
@@ -0,0 +1,95 @@
+Collection
+==========
+
+The Collection class is a generic base for the Collection pattern: you
+can have a Collection of branches, a Collection of translation templates
+and so on.  This is a more flexible and generic version of Sets and
+Subsets.
+
+The base Collection class is a very thin wrapper around Storm.  You use
+it by deriving your own collection type from it.  In this example, we
+look at collections of Kumquats.
+
+Kumquats are very simple things.  All they have is a key to identify
+them.
+
+    >>> from storm.locals import Count, Int, Storm
+    >>> from canonical.launchpad.webapp.interfaces import (
+    ...     IStoreSelector, MAIN_STORE, MASTER_FLAVOR)
+
+    >>> store = getUtility(IStoreSelector).get(MAIN_STORE, MASTER_FLAVOR)
+    >>> ok = store.execute("CREATE TEMP TABLE Kumquat(id integer UNIQUE)")
+    >>> class Kumquat(Storm):
+    ...     __storm_table__ = 'Kumquat'
+    ...     id = Int(primary=True)
+    ...     def __init__(self, id):
+    ...         self.id = id
+    ...     def __repr__(self):
+    ...         return "Kumquat-%d" % self.id
+
+    >>> obj = store.add(Kumquat(1))
+    >>> obj = store.add(Kumquat(2))
+
+A custom KumquatCollection class derives from Collection.  The
+starting_table attribute tells Collection what it is a collection of.
+
+    >>> from lp.services.database.collection import Collection
+    >>> class KumquatCollection(Collection):
+    ...     starting_table = Kumquat
+
+The collection starts out "containing" all kumquats.  Nothing is queried
+yet until you invoke the "select" method, which returns a Storm result
+set.
+
+    >>> collection = KumquatCollection()
+    >>> print list(collection.select().order_by(Kumquat.id))
+    [Kumquat-1, Kumquat-2]
+
+Actually, select() is just shorthand for select(Kumquat).
+
+    >>> print list(collection.select(Kumquat).order_by(Kumquat.id))
+    [Kumquat-1, Kumquat-2]
+
+You can also query individual columns.
+
+    >>> list(collection.select(Kumquat.id).order_by(Kumquat.id))
+    [1, 2]
+
+Since the select method returns a result set, you can even use aggregate
+functions.
+
+    >>> [int(count) for count in collection.select(Count())]
+    [2]
+
+You can refine the matching conditions using the refine method.
+Collections are immutable, so all refinements create modified copies of
+the original.
+
+    >>> one = collection.refine(Kumquat.id == 1)
+    >>> list(one.select(Kumquat.id))
+    [1]
+
+You can join in arbitrary other classes, such as Guava.
+
+    >>> ok = store.execute("CREATE TEMP TABLE Guava(id integer UNIQUE)")
+    >>> class Guava(Storm):
+    ...     __storm_table__ = 'Guava'
+    ...     id = Int(primary=True)
+    ...     def __init__(self, id):
+    ...         self.id = id
+    ...     def __repr__(self):
+    ...         return "Guava-%d" % self.id
+    >>> obj = store.add(Guava(1))
+    >>> obj = store.add(Guava(3))
+    >>> join = collection.joinInner(Guava, Guava.id == Kumquat.id)
+
+This includes the ability to return multiple values from the join.
+
+    >>> list(join.select(Kumquat, Guava))
+    [(Kumquat-1, Guava-1)]
+
+Outer joins work in the same way.
+
+    >>> join = collection.joinOuter(Guava, Guava.id == Kumquat.id)
+    >>> list(join.select(Kumquat, Guava).order_by(Kumquat.id))
+    [(Kumquat-1, Guava-1), (Kumquat-2, None)]

=== modified file 'lib/lp/translations/interfaces/potemplate.py'
--- lib/lp/translations/interfaces/potemplate.py	2010-06-03 16:08:33 +0000
+++ lib/lp/translations/interfaces/potemplate.py	2010-07-16 14:43:43 +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=E0211,E0213
@@ -706,17 +706,44 @@
     Examples include `ISourcePackage`, `IDistroSeries`, and `IProductSeries`.
     """
 
+    has_translation_templates = Bool(
+        title=_("Does this object have any translation templates?"),
+        readonly=True)
+
     has_current_translation_templates = Bool(
         title=_("Does this object have current translation templates?"),
         readonly=True)
 
+    def getTemplatesCollection():
+        """Return templates as a `TranslationTemplatesCollection`.
+
+        The collection selects all `POTemplate`s attached to the
+        translation target that implements this interface.
+        """
+
+    def getCurrentTemplatesCollection():
+        """Return `TranslationTemplatesCollection` of current templates.
+
+        A translation template is considered active when both
+        `IPOTemplate`.iscurrent and the `official_rosetta` flag for its
+        containing `Product` or `Distribution` are set to True.
+        """
+        # XXX JeroenVermeulen 2010-07-16 bug=605924: Move the
+        # official_rosetta distinction into browser code.
+
     def getCurrentTranslationTemplates(just_ids=False):
         """Return an iterator over all active translation templates.
 
+        :param just_ids: If True, return only the `POTemplate.id` rather
+            than the full `POTemplate`.  Used to save time on retrieving
+            and deserializing the objects from the database.
+
         A translation template is considered active when both
-        `IPOTemplate`.iscurrent and parent official_rosetta flags
-        are set to True.
+        `IPOTemplate`.iscurrent and the `official_rosetta` flag for its
+        containing `Product` or `Distribution` are set to True.
         """
+        # XXX JeroenVermeulen 2010-07-16 bug=605924: Move the
+        # official_rosetta distinction into browser code.
 
     def getCurrentTranslationFiles(just_ids=False):
         """Return an iterator over all active translation files.
@@ -745,5 +772,12 @@
         """A list of native formats for all current translation templates.
         """
 
+    def getTemplatesAndLanguageCounts():
+        """List tuples of `POTemplate` and its language count.
+
+        A template's language count is the number of `POFile`s that
+        exist for it.
+        """
+
 # Monkey patch for circular import avoidance done in
 # _schema_circular_imports.py

=== modified file 'lib/lp/translations/model/distroseries_translations_copy.py'
--- lib/lp/translations/model/distroseries_translations_copy.py	2010-06-07 02:52:21 +0000
+++ lib/lp/translations/model/distroseries_translations_copy.py	2010-07-16 14:43:43 +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).
 
 """Functions to copy translations from parent to child distroseries."""
@@ -44,7 +44,7 @@
     copier = MultiTableCopy(full_name, translation_tables, logger=logger)
 
     # Incremental copy of updates is no longer supported
-    assert len(list(child.getTranslationTemplates())) == 0, (
+    assert not child.has_translation_templates, (
            "The child series must not yet have any translation templates.")
 
     logger.info(

=== modified file 'lib/lp/translations/model/potemplate.py'
--- lib/lp/translations/model/potemplate.py	2010-05-27 14:38:56 +0000
+++ lib/lp/translations/model/potemplate.py	2010-07-16 14:43:43 +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
@@ -13,6 +13,7 @@
     'POTemplateSet',
     'POTemplateSubset',
     'POTemplateToTranslationFileDataAdapter',
+    'TranslationTemplatesCollection',
     ]
 
 import datetime
@@ -24,7 +25,7 @@
 from sqlobject import (
     BoolCol, ForeignKey, IntCol, SQLMultipleJoin, SQLObjectNotFound,
     StringCol)
-from storm.expr import Alias, And, Desc, In, Join, LeftJoin, Or, SQL
+from storm.expr import And, Count, Desc, In, Join, LeftJoin, Or
 from storm.info import ClassAlias
 from storm.store import Store
 from zope.component import getAdapter, getUtility
@@ -41,6 +42,7 @@
 from canonical.launchpad.interfaces.lpstorm import IStore
 from lp.translations.utilities.rosettastats import RosettaStats
 from lp.services.database.prejoin import prejoin
+from lp.services.database.collection import Collection
 from lp.services.worlddata.model.language import Language
 from lp.registry.interfaces.person import validate_public_person
 from lp.registry.model.sourcepackagename import SourcePackageName
@@ -90,16 +92,15 @@
 '''
 
 standardTemplateHeader = (
-"Project-Id-Version: %(origin)s\n"
-"Report-Msgid-Bugs-To: FULL NAME <EMAIL@ADDRESS>\n"
-"POT-Creation-Date: %(templatedate)s\n"
-"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
-"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
-"Language-Team: %(languagename)s <%(languagecode)s@xxxxxx>\n"
-"MIME-Version: 1.0\n"
-"Content-Type: text/plain; charset=UTF-8\n"
-"Content-Transfer-Encoding: 8bit\n"
-)
+    "Project-Id-Version: %(origin)s\n"
+    "Report-Msgid-Bugs-To: FULL NAME <EMAIL@ADDRESS>\n"
+    "POT-Creation-Date: %(templatedate)s\n"
+    "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
+    "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
+    "Language-Team: %(languagename)s <%(languagecode)s@xxxxxx>\n"
+    "MIME-Version: 1.0\n"
+    "Content-Type: text/plain; charset=UTF-8\n"
+    "Content-Transfer-Encoding: 8bit\n")
 
 
 standardPOFileHeader = (standardTemplateHeader +
@@ -1378,7 +1379,6 @@
         self.sourcepackagename = sourcepackagename
         self.product = product
 
-
     def _get_potemplate_equivalence_class(self, template):
         """Return whatever we group `POTemplate`s by for sharing purposes."""
         if template.sourcepackagename is None:
@@ -1520,58 +1520,150 @@
     """Helper class for implementing `IHasTranslationTemplates`."""
     implements(IHasTranslationTemplates)
 
-    def getCurrentTranslationTemplates(self, just_ids=False):
-        """See `IHasTranslationTemplates`."""
-        raise NotImplementedError('This must be provided when subclassing.')
+    def getTemplatesCollection(self):
+        """See `IHasTranslationTemplates`.
+
+        To be provided by derived classes.
+        """
+        raise NotImplementedError(
+            "Child class must provide getTemplatesCollection.")
+
+    def _orderTemplates(self, result):
+        """Apply the conventional ordering to a result set of templates."""
+        return result.order_by(Desc(POTemplate.priority), POTemplate.name)
+
+    def getCurrentTemplatesCollection(self, current_value=True):
+        """See `IHasTranslationTemplates`."""
+        collection = self.getTemplatesCollection()
+
+        # XXX JeroenVermeulen 2010-07-15 bug=605924: Move the
+        # official_rosetta distinction into browser code.
+        if collection.target_pillar.official_rosetta:
+            return collection.restrictCurrent(current_value)
+        else:
+            # Product/Distribution does not have translation enabled.
+            # Treat all templates as obsolete.
+            return collection.refine(not current_value)
+
+    def getCurrentTranslationTemplates(self,
+                                       just_ids=False,
+                                       current_value=True):
+        """See `IHasTranslationTemplates`."""
+        if just_ids:
+            selection = POTemplate.id
+        else:
+            selection = POTemplate
+
+        collection = self.getCurrentTemplatesCollection(current_value)
+        return self._orderTemplates(collection.select(selection))
+
+    @property
+    def has_translation_templates(self):
+        """See `IHasTranslationTemplates`."""
+        return bool(self.getTranslationTemplates().any())
 
     @property
     def has_current_translation_templates(self):
-        """Does self have current translation templates?"""
-        templates = self.getCurrentTranslationTemplates()
-        return bool(templates.any())
+        """See `IHasTranslationTemplates`."""
+        return bool(self.getCurrentTranslationTemplates(just_ids=True).any())
 
     def getCurrentTranslationFiles(self, just_ids=False):
         """See `IHasTranslationTemplates`."""
-        # XXX 2009-06-30 Danilo: until Storm can do find() on
-        # ResultSets (bug #338255), we need to manually get a select
-        # and extend it with another condition.
-        current_templates = (
-            self.getCurrentTranslationTemplates()._get_select())
-        for column in current_templates.columns:
-            if column.name == 'id':
-                columns = [column]
-                break
-        assert len(columns) == 1, (
-            'There is no ID column in getCurrentTranslationTemplates '
-            'select query.')
-        current_templates.columns = columns
-        templates = Alias(current_templates, 'potemplates')
-
         if just_ids:
-            looking_for = POFile.id
+            selection = POFile.id
         else:
-            looking_for = POFile
-        store = Store.of(self)
-        if store is None:
-            # If self is a non-DB object like SourcePackage,
-            # it keeps current store in self._store.
-            store = self._store
-        return store.using(POFile, templates).find(
-            looking_for, POFile.potemplate==SQL('potemplates.id'))
+            selection = POFile
+
+        collection = self.getCurrentTemplatesCollection()
+        return collection.joinPOFile().select(selection)
 
     def getObsoleteTranslationTemplates(self):
         """See `IHasTranslationTemplates`."""
-        raise NotImplementedError('This must be provided when subclassing.')
+        # XXX JeroenVermeulen 2010-07-15 bug=605924: This returns a list
+        # whereas the analogous method for current template returns a
+        # result set.  Clean up this mess.
+        return list(self.getCurrentTranslationTemplates(current_value=False))
 
     def getTranslationTemplates(self):
         """See `IHasTranslationTemplates`."""
-        raise NotImplementedError('This must be provided when subclassing.')
+        return self._orderTemplates(self.getTemplatesCollection().select())
 
     def getTranslationTemplateFormats(self):
         """See `IHasTranslationTemplates`."""
         formats_query = self.getCurrentTranslationTemplates().order_by(
             'source_file_format').config(distinct=True)
-        formats = helpers.shortlist(
-            formats_query.values(POTemplate.source_file_format),
-            10)
-        return formats
+        return helpers.shortlist(
+            formats_query.values(POTemplate.source_file_format), 10)
+
+    def getTemplatesAndLanguageCounts(self):
+        """See `IHasTranslationTemplates`."""
+        join = self.getTemplatesCollection().joinOuterPOFile()
+        result = join.select(POTemplate, Count(POFile.id))
+        return result.group_by(POTemplate)
+
+
+class TranslationTemplatesCollection(Collection):
+    """A `Collection` of `POTemplate`."""
+    starting_table = POTemplate
+
+    # The Product or Distribution that this collection is restricted to.
+    target_pillar = None
+
+    def __init__(self, *args, **kwargs):
+        super(TranslationTemplatesCollection, self).__init__(*args, **kwargs)
+        if self.base is not None:
+            self.target_pillar = self.base.target_pillar
+
+    def restrictProductSeries(self, productseries):
+        product = productseries.product
+        new_collection = self.refine(
+            POTemplate.productseriesID == productseries.id)
+        new_collection._setTargetPillar(product)
+        return new_collection
+
+    def restrictDistroSeries(self, distroseries):
+        distribution = distroseries.distribution
+        new_collection = self.refine(
+            POTemplate.distroseriesID == distroseries.id)
+        new_collection._setTargetPillar(distribution)
+        return new_collection
+
+    def restrictSourcePackageName(self, sourcepackagename):
+        return self.refine(
+            POTemplate.sourcepackagenameID == sourcepackagename.id)
+
+    def _setTargetPillar(self, target_pillar):
+        assert (
+            self.target_pillar is None or
+            self.target_pillar == target_pillar), (
+                "Collection restricted to both %s and %s." % (
+                    self.target_pillar, target_pillar))
+        self.target_pillar = target_pillar
+
+    def restrictCurrent(self, current_value=True):
+        """Select based on `POTemplate.iscurrent`.
+
+        :param current_value: The value for `iscurrent` that you are
+            looking for.  Defaults to True, meaning this will restrict
+            to current templates.  If False, will select obsolete
+            templates instead.
+        :return: A `TranslationTemplatesCollection` based on this one,
+            but restricted to ones with the desired `iscurrent` value.
+        """
+        return self.refine(POTemplate.iscurrent == current_value)
+
+    def joinPOFile(self):
+        """Join `POFile` into the collection.
+
+        :return: A `TranslationTemplatesCollection` with an added inner
+            join to `POFile`.
+        """
+        return self.joinInner(POFile, POTemplate.id == POFile.potemplateID)
+
+    def joinOuterPOFile(self):
+        """Outer-join `POFile` into the collection.
+
+        :return: A `TranslationTemplatesCollection` with an added outer
+            join to `POFile`.
+        """
+        return self.joinOuter(POFile, POTemplate.id == POFile.potemplateID)

=== added file 'lib/lp/translations/tests/test_translationtemplatescollection.py'
--- lib/lp/translations/tests/test_translationtemplatescollection.py	1970-01-01 00:00:00 +0000
+++ lib/lp/translations/tests/test_translationtemplatescollection.py	2010-07-16 14:43:43 +0000
@@ -0,0 +1,130 @@
+# Copyright 2010 Canonical Ltd.  This software is licensed under the
+# GNU Affero General Public License version 3 (see the file LICENSE).
+
+"""Test `TranslationTemplatesCollection`."""
+
+__metaclass__ = type
+
+from zope.security.proxy import removeSecurityProxy
+from canonical.testing import DatabaseFunctionalLayer
+from lp.testing import TestCaseWithFactory
+
+from lp.translations.model.pofile import POFile
+from lp.translations.model.potemplate import (
+    POTemplate,
+    TranslationTemplatesCollection)
+
+
+class TestSomething(TestCaseWithFactory):
+    layer = DatabaseFunctionalLayer
+
+    def test_baseline(self):
+        # A collection constructed with no arguments selects all
+        # templates.
+        template = self.factory.makePOTemplate()
+
+        collection = TranslationTemplatesCollection()
+        self.assertIn(template, collection.select())
+
+    def test_restrictProductSeries(self):
+        trunk = self.factory.makeProduct().getSeries('trunk')
+        template = self.factory.makePOTemplate(productseries=trunk)
+
+        collection = TranslationTemplatesCollection()
+        by_series = collection.restrictProductSeries(trunk)
+
+        self.assertContentEqual([template], by_series.select())
+
+    def test_restrictDistroSeries(self):
+        package = self.factory.makeSourcePackage()
+        template = self.factory.makePOTemplate(
+            distroseries=package.distroseries,
+            sourcepackagename=package.sourcepackagename)
+
+        collection = TranslationTemplatesCollection()
+        by_series = collection.restrictDistroSeries(package.distroseries)
+
+        self.assertContentEqual([template], by_series.select())
+
+    def test_restrictSourcePackageName(self):
+        package = self.factory.makeSourcePackage()
+        template = self.factory.makePOTemplate(
+            distroseries=package.distroseries,
+            sourcepackagename=package.sourcepackagename)
+
+        assert package.sourcepackagename
+        collection = TranslationTemplatesCollection()
+        by_packagename = collection.restrictSourcePackageName(
+            package.sourcepackagename)
+
+        self.assertContentEqual([template], by_packagename.select())
+
+    def test_restrict_SourcePackage(self):
+        # You can restrict to a source package by restricting both to a
+        # DistroSeries and to a SourcePackageName.
+        package = self.factory.makeSourcePackage()
+        template = self.factory.makePOTemplate(
+            distroseries=package.distroseries,
+            sourcepackagename=package.sourcepackagename)
+
+        collection = TranslationTemplatesCollection()
+        by_series = collection.restrictDistroSeries(package.distroseries)
+        by_package = by_series.restrictSourcePackageName(
+            package.sourcepackagename)
+
+        self.assertContentEqual([template], by_package.select())
+
+    def test_restrictCurrent(self):
+        trunk = self.factory.makeProduct().getSeries('trunk')
+        template = self.factory.makePOTemplate(productseries=trunk)
+        collection = TranslationTemplatesCollection()
+        by_series = collection.restrictProductSeries(trunk)
+
+        current_templates = by_series.restrictCurrent(True)
+        obsolete_templates = by_series.restrictCurrent(False)
+
+        removeSecurityProxy(template).iscurrent = True
+        self.assertContentEqual(
+            [template], current_templates.select())
+        self.assertContentEqual([], obsolete_templates.select())
+
+        removeSecurityProxy(template).iscurrent = False
+        self.assertContentEqual([], current_templates.select())
+        self.assertContentEqual(
+            [template], obsolete_templates.select())
+
+    def test_joinPOFile(self):
+        trunk = self.factory.makeProduct().getSeries('trunk')
+        translated_template = self.factory.makePOTemplate(productseries=trunk)
+        untranslated_template = self.factory.makePOTemplate(
+            productseries=trunk)
+        nl = translated_template.newPOFile('nl')
+        de = translated_template.newPOFile('de')
+
+        collection = TranslationTemplatesCollection()
+        by_series = collection.restrictProductSeries(trunk)
+        joined = by_series.joinPOFile()
+
+        self.assertContentEqual(
+            [(translated_template, nl), (translated_template, de)],
+            joined.select(POTemplate, POFile))
+
+    def test_joinOuterPOFile(self):
+        trunk = self.factory.makeProduct().getSeries('trunk')
+        translated_template = self.factory.makePOTemplate(productseries=trunk)
+        untranslated_template = self.factory.makePOTemplate(
+            productseries=trunk)
+        nl = translated_template.newPOFile('nl')
+        de = translated_template.newPOFile('de')
+
+        collection = TranslationTemplatesCollection()
+        by_series = collection.restrictProductSeries(trunk)
+        joined = by_series.joinOuterPOFile()
+
+        expected_outcome = [
+            (translated_template, nl),
+            (translated_template, de),
+            (untranslated_template, None),
+            ]
+        self.assertContentEqual(
+            expected_outcome, joined.select(POTemplate, POFile))


Follow ups