Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-11-07T09:54:42+01:00
Author: Jens W. Klein (jensens) <jens@bluedynamics.com>
Commit: https://github.com/plone/plone.app.contenttypes/commit/28a8ab97f4332959f1a1778e658fd7929b8ec5c5

copy over code from maurits branch on plone.app.collection for topic migration and make it work in here

Files changed:
A plone/app/contenttypes/migration/topics.py
A plone/app/contenttypes/tests/test_migration_topic.py
M buildout.cfg
M plone/app/contenttypes/testing.py

diff --git a/buildout.cfg b/buildout.cfg
index 1e41162..cb7da75 100644
--- a/buildout.cfg
+++ b/buildout.cfg
@@ -1,3 +1,7 @@
 [buildout]
 extends = test-plone-4.3.x.cfg
 
+auto-checkout +=
+    plone.app.querystring
+  
+
diff --git a/plone/app/contenttypes/migration/topics.py b/plone/app/contenttypes/migration/topics.py
new file mode 100644
index 0000000..370a121
--- /dev/null
+++ b/plone/app/contenttypes/migration/topics.py
@@ -0,0 +1,611 @@
+# -*- coding: utf-8 -*-
+import logging
+
+from DateTime import DateTime
+from Products.CMFCore.utils import getToolByName
+from Products.CMFCore.interfaces._content import IFolderish
+from Products.contentmigration.archetypes import InplaceATFolderMigrator
+from Products.contentmigration.archetypes import InplaceATItemMigrator
+from Products.contentmigration.walker import CustomQueryWalker
+from plone.app.querystring.interfaces import IQuerystringRegistryReader
+from plone.registry.interfaces import IRegistry
+from zope.component import getUtility
+from zope.dottedname.resolve import resolve
+
+logger = logging.getLogger('plone.app.collection')
+prefix = "plone.app.querystring"
+
+INVALID_OPERATION = 'Invalid operation %s for criterion: %s'
+PROFILE_ID = 'profile-plone.app.collection:default'
+
+
+def format_date(value):
+    """Format the date.
+
+    The value is expected to be a DateTime.DateTime object, though it
+    actually also works on datetime.datetime objects.
+
+    The query field expects a string with month/date/year.
+    So 28 March 2013 should become '03/28/2013'.
+    """
+    return value.strftime('%m/%d/%Y')
+
+
+def is_old_non_folderish_item(obj, **kwargs):
+    """Is this an old not yet migrated Collection item?
+
+    The old non-folderish and new folderish Collections have the same
+    meta_type and portal_type, which means a simple catalog walker
+    will crash when it is called on a new folderish Collection, for
+    example when the upgrade step is run twice.  We can use this
+    function to ignore the new Collections.
+    """
+    return not IFolderish(obj)
+
+
+# Converters
+
+class CriterionConverter(object):
+
+    # Last part of the code for the dotted operation method,
+    # e.g. 'string.contains'.
+    operator_code = ''
+    # alternative code, possibly used if the first code does not work.
+    alt_operator_code = ''
+
+    def get_query_value(self, value, index, criterion):
+        # value may contain a query and some parameters, but in the
+        # simple case it is simply a value.
+        return value
+
+    def get_operation(self, value, index, criterion):
+        # Get dotted operation method.  This may depend on value.
+        return "%s.operation.%s" % (prefix, self.operator_code)
+
+    def get_alt_operation(self, value, index, criterion):
+        # Get dotted operation method.  This may depend on value.
+        return "%s.operation.%s" % (prefix, self.alt_operator_code)
+
+    def is_index_known(self, registry, index):
+        # Is the index registered as criterion index?
+        key = '%s.field.%s' % (prefix, index)
+        try:
+            registry.get(key)
+        except KeyError:
+            logger.error("Index %s is no criterion index. Registry gives "
+                         "KeyError: %s", index, key)
+            return False
+        return True
+
+    def is_index_enabled(self, registry, index):
+        # Is the index enabled as criterion index?
+        key = '%s.field.%s' % (prefix, index)
+        index_data = registry.get(key)
+        if index_data.get('enabled'):
+            return True
+        logger.warn("Index %s is not enabled as criterion index. ", index)
+        return False
+
+    def switch_type_to_portal_type(self, value, criterion):
+        # 'portal_type' is the object id of the FTI in portal_types.
+        # 'Type' is the title of that object.
+        # For example:
+        # - portal_type 'Document' has Type 'Page'.
+        # - portal_type 'Topic' has Type 'Collection (old)'.
+        if isinstance(value, dict):
+            values = value.get('query', [])
+        else:
+            values = value
+        if not values:
+            return value
+        new_values = []
+        ttool = getToolByName(criterion, 'portal_types')
+        type_to_portal_type = {}
+        portal_types = ttool.objectIds()
+        for portal_type, Type in ttool.listTypeTitles().items():
+            type_to_portal_type[Type] = portal_type
+        for Type in values:
+            portal_type = type_to_portal_type.get(Type)
+            if not portal_type:
+                if Type in portal_types:
+                    portal_type = Type
+                else:
+                    logger.warn("Cannot switch Type %r to portal_type.", Type)
+                    continue
+            new_values.append(portal_type)
+        new_values = tuple(new_values)
+        if isinstance(value, dict):
+            value['query'] = new_values
+        else:
+            value = new_values
+        return value
+
+    def is_operation_valid(self, registry, operation):
+        # Check that the operation exists.
+        op_info = registry.get(operation)
+        if op_info is None:
+            logger.error("Operation %r is not defined.", operation)
+            return False
+        op_function_name = op_info.get('operation')
+        try:
+            resolve(op_function_name)
+        except ImportError:
+            logger.error("ImportError for operation %r: %s",
+                         operation, op_function_name)
+            return False
+        return True
+
+    def get_valid_operation(self, registry, index, value, criterion):
+        key = '%s.field.%s.operations' % (prefix, index)
+        operations = registry.get(key)
+        operation = self.get_operation(value, index, criterion)
+        if not operation in operations:
+            operation = self.get_alt_operation(value, index, criterion)
+            if not operation in operations:
+                return
+        if self.is_operation_valid(registry, operation):
+            return operation
+
+    def __call__(self, formquery, criterion, registry):
+        criteria = criterion.getCriteriaItems()
+        if not criteria:
+            logger.warn("Ignoring empty criterion %s.", criterion)
+            return
+        for index, value in criteria:
+            # Check if the index is known and enabled as criterion index.
+            if index == 'Type':
+                # Try to replace Type by portal_type
+                index = 'portal_type'
+                value = self.switch_type_to_portal_type(value, criterion)
+            if not self.is_index_known(registry, index):
+                logger.info("Index %s not known in registry.", index)
+                continue
+            self.is_index_enabled(registry, index)
+            # TODO: what do we do when this is False?  Raise an
+            # Exception?  Continue processing the index and value
+            # anyway, now that a warning is logged?  Continue with the
+            # next criteria item?
+
+            # Get the operation method.
+            operation = self.get_valid_operation(
+                registry,
+                index,
+                value,
+                criterion
+            )
+            if not operation:
+                logger.error(INVALID_OPERATION % (operation, criterion))
+                # TODO: raise an Exception?
+                continue
+
+            # Get the value that we will query for.
+            query_value = self.get_query_value(value, index, criterion)
+
+            # Add a row to the form query.
+            row = {'i': index,
+                   'o': operation}
+            if query_value is not None:
+                row['v'] = query_value
+            formquery.append(row)
+
+
+class ATDateCriteriaConverter(CriterionConverter):
+    """Handle date criteria.
+
+    Note that there is also ATDateRangeCriterion, which is much
+    simpler as it just has two dates.
+
+    In our case we have these valid operations:
+
+    ['plone.app.querystring.operation.date.lessThan',
+     'plone.app.querystring.operation.date.largerThan',
+     'plone.app.querystring.operation.date.between',
+     'plone.app.querystring.operation.date.lessThanRelativeDate',
+     'plone.app.querystring.operation.date.largerThanRelativeDate',
+     'plone.app.querystring.operation.date.today',
+     'plone.app.querystring.operation.date.beforeToday',
+     'plone.app.querystring.operation.date.afterToday']
+
+    This code is based on the getCriteriaItems method from
+    Products/ATContentTypes/criteria/date.py.  We check the field
+    values ourselves instead of translating the values back and forth.
+    """
+
+    def __call__(self, formquery, criterion, registry):
+        if criterion.value is None:
+            logger.warn("Ignoring empty criterion %s.", criterion)
+            return
+        field = criterion.Field()
+        value = criterion.Value()
+
+        # Check if the index is known and enabled as criterion index.
+        if not self.is_index_known(registry, field):
+            return
+        self.is_index_enabled(registry, field)
+
+        # Negate the value for 'old' days
+        if criterion.getDateRange() == '-':
+            value = -value
+
+        date = DateTime() + value
+
+        # Get the possible operation methods.
+        key = '%s.field.%s.operations' % (prefix, field)
+        operations = registry.get(key)
+
+        def add_row(operation, value=None):
+            if not operation in operations:
+                # TODO just ignore it?
+                raise ValueError(INVALID_OPERATION % (operation, criterion))
+            if not self.is_operation_valid(registry, operation):
+                # TODO just ignore it?
+                raise ValueError(INVALID_OPERATION % (operation, criterion))
+            # Add a row to the form query.
+            row = {'i': field,
+                   'o': operation}
+            if value is not None:
+                row['v'] = value
+            formquery.append(row)
+
+        operation = criterion.getOperation()
+        if operation == 'within_day':
+            if date.isCurrentDay():
+                new_operation = "%s.operation.date.today" % prefix
+                add_row(new_operation)
+                return
+            date_range = (date.earliestTime(), date.latestTime())
+            new_operation = "%s.operation.date.between" % prefix
+            add_row(new_operation, date_range)
+            return
+        elif operation == 'more':
+            if value != 0:
+                new_operation = ("{0}.operation.date."
+                                 "largerThanRelativeDate".format(prefix))
+                add_row(new_operation, value)
+                return
+            else:
+                new_operation = "{0}.operation.date.afterToday".format(prefix)
+                add_row(new_operation)
+                return
+        elif operation == 'less':
+            if value != 0:
+                new_operation = ("{0}.operation.date."
+                                 "lessThanRelativeDate".format(prefix))
+                add_row(new_operation, value)
+                return
+            else:
+                new_operation = "{0}.operation.date.beforeToday".format(prefix)
+                add_row(new_operation)
+                return
+
+
+class ATSimpleStringCriterionConverter(CriterionConverter):
+    operator_code = 'string.contains'
+    # review_state could be a string criterion, but should become a selection.
+    alt_operator_code = 'selection.is'
+
+
+class ATCurrentAuthorCriterionConverter(CriterionConverter):
+    operator_code = 'string.currentUser'
+
+
+class ATSelectionCriterionConverter(CriterionConverter):
+    operator_code = 'selection.is'
+
+    def get_query_value(self, value, index, criterion):
+        values = value['query']
+        if value.get('operator') == 'and' and len(values) > 1:
+            logger.warn("Cannot handle selection operator 'and'. Using 'or'. "
+                        "%r", value)
+        values = value['query']
+        # Special handling for portal_type=Topic.
+        if index == 'portal_type' and 'Topic' in values:
+            values = list(values)
+            values[values.index('Topic')] = 'Collection'
+            values = tuple(values)
+        return values
+
+
+class ATListCriterionConverter(ATSelectionCriterionConverter):
+    pass
+
+
+class ATReferenceCriterionConverter(ATSelectionCriterionConverter):
+    # Note: the new criterion is disabled by default.  Also, it
+    # needs the _referenceIs function in the plone.app.querystring
+    # queryparser and that function is not defined.
+    operator_code = 'reference.is'
+
+
+class ATPathCriterionConverter(CriterionConverter):
+    operator_code = 'string.path'
+
+    def get_query_value(self, value, index, criterion):
+        if not criterion.Recurse():
+            logger.warn("Cannot handle non-recursive path search. "
+                        "Allowing recursive search. %r", value)
+        raw = criterion.getRawValue()
+        if not raw:
+            return
+        if len(raw) > 1:
+            logger.warn("Multiple paths in query. Using only the first. %r",
+                        value['query'])
+        return raw[0]
+
+
+class ATBooleanCriterionConverter(CriterionConverter):
+
+    def get_operation(self, value, index, criterion):
+        # Get dotted operation method.
+        # value is one of these beauties:
+        # value = [1, True, '1', 'True']
+        # value = [0, '', False, '0', 'False', None, (), [], {}, MV]
+        if True in value:
+            code = 'isTrue'
+        elif False in value:
+            code = 'isFalse'
+        else:
+            logger.warn("Unknown value for boolean criterion. "
+                        "Falling back to True. %r", value)
+            code = 'isTrue'
+        return "%s.operation.boolean.%s" % (prefix, code)
+
+    def __call__(self, formquery, criterion, registry):
+        criteria = criterion.getCriteriaItems()
+        if not criteria:
+            return
+        for index, value in criteria:
+            if index == 'is_folderish':
+                fieldname = 'isFolderish'
+            elif index == 'is_default_page':
+                fieldname = 'isDefaultPage'
+            else:
+                fieldname = index
+            # Check if the index is known and enabled as criterion index.
+            if not self.is_index_known(registry, fieldname):
+                continue
+            self.is_index_enabled(registry, fieldname)
+            # Get the operation method.
+            operation = self.get_valid_operation(registry, fieldname, value, criterion)
+            if not operation:
+                logger.error(INVALID_OPERATION % (operation, criterion))
+                # TODO: raise an Exception?
+                continue
+            # Add a row to the form query.
+            row = {'i': index,
+                   'o': operation}
+            formquery.append(row)
+
+
+class ATDateRangeCriterionConverter(CriterionConverter):
+    operator_code = 'date.between'
+
+    def get_query_value(self, value, index, criterion):
+        return value['query']
+
+
+class ATPortalTypeCriterionConverter(CriterionConverter):
+    operator_code = 'selection.is'
+
+    def get_query_value(self, value, index, criterion):
+        # Special handling for portal_type=Topic.
+        if 'Topic' in value:
+            value = list(value)
+            value[value.index('Topic')] = 'Collection'
+            value = tuple(value)
+        return value
+
+
+class ATRelativePathCriterionConverter(CriterionConverter):
+    # We also have path.isWithinRelative, but its function is not defined.
+    operator_code = 'string.relativePath'
+
+    def get_query_value(self, value, index, criterion):
+        if not criterion.Recurse():
+            logger.warn("Cannot handle non-recursive path search. "
+                        "Allowing recursive search. %r", value)
+        return criterion.getRelativePath()
+
+
+class ATSimpleIntCriterionConverter(CriterionConverter):
+    # Also available: int.lessThan, int.largerThan.
+    operator_code = 'int.is'
+
+    def get_operation(self, value, index, criterion):
+        # Get dotted operation method.
+        import pdb;pdb.set_trace()
+        direction = value.get('range')
+        if not direction:
+            code = 'is'
+        elif direction == 'min':
+            code = 'largerThan'
+        elif direction == 'max':
+            code = 'lessThan'
+        elif direction == 'min:max':
+            logger.warn("min:max direction not supported for integers. %r",
+                        value)
+            return
+        else:
+            logger.warn("Unknown direction for integers. %r", value)
+            return
+        return "{0}.operation.int.{1}".format(prefix, code)
+
+    def get_query_value(self, value, index, criterion):
+        if isinstance(value['query'], tuple):
+            logger.warn("More than one integer is not supported. %r", value)
+            return
+        return value['query']
+
+
+class TopicMigrator(InplaceATFolderMigrator):
+    src_portal_type = 'Topic'
+    src_meta_type = 'ATTopic'
+    dst_portal_type = dst_meta_type = 'Collection'
+    view_methods_mapping = {
+        'folder_listing': 'standard_view',
+        'folder_summary_view': 'summary_view',
+        'folder_full_view': 'all_content',
+        'folder_tabular_view': 'tabular_view',
+        'atct_album_view': 'thumbnail_view',
+        'atct_topic_view': 'standard_view',
+        }
+
+    @property
+    def registry(self):
+        return self.kwargs['registry']
+
+    def last_migrate_layout(self):
+        """Migrate the layout (view method).
+
+        This needs to be done last, as otherwise our changes in
+        migrate_criteria may get overriden by a later call to
+        migrate_properties.
+        """
+        if self.old.getCustomView():
+            # Previously, the atct_topic_view had logic for showing
+            # the results in a list or in tabular form.  If
+            # getCustomView is True, this means the new object should
+            # use the tabular view.
+            self.new.setLayout('tabular_view')
+            return
+
+        layout = self.view_methods_mapping.get(self.old.getLayout())
+        if layout:
+            self.new.setLayout(layout)
+
+    def beforeChange_criteria(self):
+        """Store the criteria of the old Topic.
+
+        Store the info on the migrator and restore the values in the
+        migrate_criteria method.
+        """
+        self._collection_sort_reversed = None
+        self._collection_sort_on = None
+        self._collection_query = None
+        path = '/'.join(self.old.getPhysicalPath())
+        logger.info("Migrating %s at %s", self.src_portal_type, path)
+        # Get the old criteria.
+        # See also Products.ATContentTypes.content.topic.buildQuery
+        criteria = self.old.listCriteria()
+        logger.debug("Old criteria for %s: %r", path,
+                     [(crit, crit.getCriteriaItems()) for crit in criteria])
+        formquery = []
+        for criterion in criteria:
+            type_ = criterion.__class__.__name__
+            if type_ == 'ATSortCriterion':
+                # Sort order and direction are now stored in the Collection.
+                self._collection_sort_reversed = criterion.getReversed()
+                self._collection_sort_on = criterion.Field()
+                logger.debug("Sort on %r, reverse: %s.",
+                             self._collection_sort_on,
+                             self._collection_sort_reversed)
+                continue
+
+            converter = CONVERTERS.get(type_)
+            if converter is None:
+                msg = 'Unsupported criterion %s' % type_
+                logger.error(msg)
+                raise ValueError(msg)
+            converter(formquery, criterion, self.registry)
+
+        logger.debug("New query for %s: %r", path, formquery)
+        self._collection_query = formquery
+
+    def migrate_criteria(self):
+        """Migrate old style to new style criteria.
+
+        Plus handling for some special fields.
+        """
+        # The old Topic has boolean limitNumber and integer itemCount,
+        # where the new Collection only has limit.
+        if self.old.getLimitNumber():
+            self.new.setLimit(self.old.getItemCount())
+
+        # Get the old data stores by the beforeChange_criteria method.
+        if self._collection_sort_reversed is not None:
+            self.new.setSort_reversed(self._collection_sort_reversed)
+        if self._collection_sort_on is not None:
+            self.new.setSort_on(self._collection_sort_on)
+        if self._collection_query is not None:
+            self.new.setQuery(self._collection_query)
+
+
+class FolderishCollectionMigrator(InplaceATItemMigrator):
+    src_portal_type = src_meta_type = 'Collection'
+    dst_portal_type = dst_meta_type = 'Collection'
+
+
+def migrate_to_folderish_collections(context):
+    """Migrate new-style Collections to folderish Collections.
+
+    This can be used as upgrade step.
+
+    The new-style Collections started out as inheriting from
+    ATDocument.  Historically users could nest topics, so we want to
+    try to bring that back.  This is the first step: make existing
+    new-style Collections folderish.
+
+    TODO/notes:
+
+    - This simple migration seems to work.
+
+    - The sub collection should 'inherit' the query from its parent,
+      otherwise this exercise does not make much sense.  See the
+      maurits-recursive branch of archetypes.querywidget, which seems
+      to work, though for the tests to pass it currently needs the
+      maurits-upgradepath branch of plone.app.collection.
+
+    """
+    site = getToolByName(context, 'portal_url').getPortalObject()
+    collection_walker = CustomQueryWalker(
+        site, FolderishCollectionMigrator,
+        callBefore=is_old_non_folderish_item)
+    collection_walker.go()
+
+
+def run_typeinfo_step(context):
+    context.runImportStepFromProfile(PROFILE_ID, 'typeinfo')
+
+
+def run_actions_step(context):
+    context.runImportStepFromProfile(PROFILE_ID, 'actions')
+
+
+def run_propertiestool_step(context):
+    context.runImportStepFromProfile(PROFILE_ID, 'propertiestool')
+
+
+def migrate_topics(context):
+    """Migrate ATContentTypes Topics to plone.app.collection Collections.
+
+    This can be used as upgrade step.
+
+    The new-style Collections might again get some changes later.
+    They may become folderish or dexterity items or dexterity
+    containers or a dexterity behavior.
+    """
+    site = getToolByName(context, 'portal_url').getPortalObject()
+    topic_walker = CustomQueryWalker(site, TopicMigrator)
+    # Parse the registry to get allowed operations and pass it to the
+    # migrator.
+    reg = getUtility(IRegistry)
+    reader = IQuerystringRegistryReader(reg)
+    registry = reader.parseRegistry()
+    topic_walker.go(registry=registry)
+
+
+CONVERTERS = {
+    # Create an instance of each converter.
+    'ATBooleanCriterion': ATBooleanCriterionConverter(),
+    'ATCurrentAuthorCriterion': ATCurrentAuthorCriterionConverter(),
+    'ATDateCriteria': ATDateCriteriaConverter(),
+    'ATDateRangeCriterion': ATDateRangeCriterionConverter(),
+    'ATListCriterion': ATListCriterionConverter(),
+    'ATPathCriterion': ATPathCriterionConverter(),
+    'ATPortalTypeCriterion': ATPortalTypeCriterionConverter(),
+    'ATReferenceCriterion': ATReferenceCriterionConverter(),
+    'ATRelativePathCriterion': ATRelativePathCriterionConverter(),
+    'ATSelectionCriterion': ATSelectionCriterionConverter(),
+    'ATSimpleIntCriterion': ATSimpleIntCriterionConverter(),
+    'ATSimpleStringCriterion': ATSimpleStringCriterionConverter(),
+    }
diff --git a/plone/app/contenttypes/testing.py b/plone/app/contenttypes/testing.py
index 9e6d8ed..d292628 100644
--- a/plone/app/contenttypes/testing.py
+++ b/plone/app/contenttypes/testing.py
@@ -78,6 +78,7 @@ def setUpZope(self, app, configurationContext):
         z2.installProduct(app, 'Products.Archetypes')
         z2.installProduct(app, 'Products.ATContentTypes')
         z2.installProduct(app, 'plone.app.blob')
+
         # prepare installing plone.app.collection
         try:
             pkg_resources.get_distribution('plone.app.collection')
@@ -112,6 +113,9 @@ def setUpPloneSite(self, portal):
         if 'Products.ATContentTypes:default' in profiles:
             applyProfile(portal, 'Products.ATContentTypes:default')
 
+        # enable old Topic
+        portal.portal_types.Topic.global_allow = True
+
         # install plone.app.collections manually if profile is available
         # (this is only needed for Plone >= 5)
         if 'plone.app.collection:default' in profiles:
diff --git a/plone/app/contenttypes/tests/test_migration_topic.py b/plone/app/contenttypes/tests/test_migration_topic.py
new file mode 100644
index 0000000..4d95d4e
--- /dev/null
+++ b/plone/app/contenttypes/tests/test_migration_topic.py
@@ -0,0 +1,681 @@
+# -*- coding: utf-8 -*-
+from DateTime import DateTime
+from Products.CMFCore.utils import getToolByName
+from plone.app.contenttypes.migration.topics import migrate_topics
+from plone.app.contenttypes.testing import \
+    PLONE_APP_CONTENTTYPES_MIGRATION_TESTING
+from plone.app.testing import login
+
+import unittest
+
+
+class MigrateToATContentTypesTest(unittest.TestCase):
+
+    layer = PLONE_APP_CONTENTTYPES_MIGRATION_TESTING
+
+    def setUp(self):
+        self.portal = self.layer['portal']
+        self.request = self.layer['request']
+        self.request['ACTUAL_URL'] = self.portal.absolute_url()
+        self.request['URL'] = self.portal.absolute_url()
+        self.catalog = getToolByName(self.portal, "portal_catalog")
+        self.portal.acl_users.userFolderAddUser('admin',
+                                                'secret',
+                                                ['Manager'],
+                                                [])
+        login(self.portal, 'admin')
+        self.portal.portal_workflow.setDefaultChain(
+            "simple_publication_workflow")
+        self.portal.invokeFactory("Topic", "topic", title="Topic")
+        self.portal.invokeFactory("Folder", "folder", title="Folder")
+
+    def run_migration(self):
+        migrate_topics(getToolByName(self.portal, 'portal_setup'))
+
+    def add_criterion(self, index, criterion, value=None):
+        name = '%s_%s' % (index, criterion)
+        self.portal.topic.addCriterion(index, criterion)
+        crit = self.portal.topic.getCriterion(name)
+        if value is not None:
+            crit.setValue(value)
+        return crit
+
+    def test_migrate_simple_topic(self):
+        self.assertEqual(self.portal.topic.portal_type, 'Topic')
+        self.assertEqual(self.portal.topic.getLayout(), 'atct_topic_view')
+        self.assertEqual(self.portal.topic.getAcquireCriteria(), False)
+        self.assertEqual(self.portal.topic.getLimitNumber(), False)
+        self.assertEqual(self.portal.topic.getItemCount(), 0)
+        self.assertEqual(self.portal.topic.getCustomViewFields(), ('Title',))
+        self.run_migration()
+        self.assertEqual(self.portal.topic.portal_type, 'Collection')
+        self.assertEqual(self.portal.topic.getLayout(), 'standard_view')
+
+        # TODO: Check how to proceed with the missing method
+        # self.assertEqual(self.portal.topic.getAcquireCriteria(), False)
+        self.assertEqual(self.portal.topic.getSort_on(), 'sortable_title')
+        self.assertEqual(self.portal.topic.getSort_reversed(), False)
+        self.assertEqual(self.portal.topic.getLimit(), 1000)
+        self.assertEqual(self.portal.topic.getCustomViewFields(), ('Title',))
+
+    def test_migrate_topic_fields(self):
+        self.portal.topic.setAcquireCriteria(True)
+        self.portal.topic.setText('<p>Hello</p>')
+        self.portal.topic.setLimitNumber(True)
+        self.portal.topic.setItemCount(42)
+        self.portal.topic.setCustomViewFields(('Title', 'Type'))
+        self.run_migration()
+        self.assertEqual(self.portal.topic.portal_type, 'Collection')
+
+        # TODO: Check how to proceed with the missing method
+        # self.assertEqual(self.portal.topic.getAcquireCriteria(), True)
+        self.assertEqual(self.portal.topic.getLimit(), 42)
+        self.assertEqual(self.portal.topic.getCustomViewFields(),
+                         ('Title', 'Type'))
+
+    def test_migrate_layout(self):
+        self.portal.topic.setAcquireCriteria(True)
+        self.portal.topic.setLayout('folder_summary_view')
+        self.run_migration()
+        self.assertEqual(self.portal.topic.getLayout(), 'summary_view')
+
+    def test_migrate_customView(self):
+        self.portal.topic.setAcquireCriteria(True)
+        self.portal.topic.setCustomView(True)
+        self.run_migration()
+        self.assertEqual(self.portal.topic.getLayout(), 'tabular_view')
+
+    def test_migrate_nested_topic(self):
+        self.portal.portal_types.Topic.filter_content_types = False
+        self.portal.portal_types.Collection.filter_content_types = False
+        self.portal.topic.invokeFactory("Topic", "subtopic", title="Sub Topic")
+        self.run_migration()
+        self.assertEqual(self.portal.topic.portal_type, 'Collection')
+        self.assertEqual(self.portal.topic.subtopic.portal_type, 'Collection')
+
+    def test_ATSimpleStringCriterion(self):
+        self.add_criterion('SearchableText', 'ATSimpleStringCriterion', 'bar')
+        self.run_migration()
+        self.assertEqual(
+            self.portal.topic.getRawQuery(),
+            [{'i': 'SearchableText',
+              'o': 'plone.app.querystring.operation.string.contains',
+              'v': 'bar'}]
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSimpleStringCriterionToSelection(self):
+        # Some string criterions really should be selection criterions.
+        self.add_criterion(
+            'review_state',
+            'ATSimpleStringCriterion', 'published'
+        )
+        self.run_migration()
+        self.assertEqual(
+            self.portal.topic.getRawQuery(),
+            [{'i': 'review_state',
+              'o': 'plone.app.querystring.operation.selection.is',
+              'v': 'published'}]
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATDateCriteriaPast(self):
+        # More than 5 days in the past:
+        crit = self.add_criterion('created', 'ATFriendlyDateCriteria', 5)
+        crit.setOperation('more')
+        crit.setDateRange('-')
+        # Less than 5 days in the past:
+        crit = self.add_criterion('effective', 'ATFriendlyDateCriteria', 5)
+        crit.setOperation('less')
+        crit.setDateRange('-')
+        # The next two are logically a bit weird.
+        # More than 0 days in the past is historically interpreted as: after
+        # today.
+        crit = self.add_criterion('expires', 'ATFriendlyDateCriteria', 0)
+        crit.setOperation('more')
+        crit.setDateRange('-')
+        # Less than 0 days in the past is historically interpreted as: before
+        # today.
+        crit = self.add_criterion('modified', 'ATFriendlyDateCriteria', 0)
+        crit.setOperation('less')
+        crit.setDateRange('-')
+
+        self.run_migration()
+        query = self.portal.topic.getRawQuery()
+        self.assertEqual(len(query), 4)
+
+        self.assertEqual(query[0]['i'], 'created')
+        self.assertEqual(
+            query[0]['o'],
+            'plone.app.querystring.operation.date.largerThanRelativeDate'
+        )
+        self.assertEqual(query[0]['v'], -5)
+
+        self.assertEqual(query[1]['i'], 'effective')
+        self.assertEqual(
+            query[1]['o'],
+            'plone.app.querystring.operation.date.lessThanRelativeDate'
+        )
+        self.assertEqual(query[1]['v'], -5)
+
+        self.assertEqual(query[2]['i'], 'expires')
+        self.assertEqual(
+            query[2]['o'],
+            'plone.app.querystring.operation.date.afterToday'
+        )
+        self.assertTrue('v' not in query[2].keys())
+
+        self.assertEqual(query[3]['i'], 'modified')
+        self.assertEqual(
+            query[3]['o'],
+            'plone.app.querystring.operation.date.beforeToday'
+        )
+        self.assertTrue('v' not in query[3].keys())
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATDateCriteriaFuture(self):
+        # More than 5 days in the future:
+        crit = self.add_criterion('created', 'ATFriendlyDateCriteria', 5)
+        crit.setOperation('more')
+        crit.setDateRange('+')
+        # Less than 5 days in the future:
+        crit = self.add_criterion('effective', 'ATFriendlyDateCriteria', 5)
+        crit.setOperation('less')
+        crit.setDateRange('+')
+        # More than 0 days in the future: after today.
+        crit = self.add_criterion('expires', 'ATFriendlyDateCriteria', 0)
+        crit.setOperation('more')
+        crit.setDateRange('+')
+        # Less than 0 days in the future: before today.
+        crit = self.add_criterion('modified', 'ATFriendlyDateCriteria', 0)
+        crit.setOperation('less')
+        crit.setDateRange('+')
+
+        self.run_migration()
+        query = self.portal.topic.getRawQuery()
+        self.assertEqual(len(query), 4)
+
+        self.assertEqual(query[0]['i'], 'created')
+        self.assertEqual(
+            query[0]['o'],
+            'plone.app.querystring.operation.date.largerThanRelativeDate'
+        )
+        self.assertEqual(query[0]['v'], 5)
+
+        self.assertEqual(query[1]['i'], 'effective')
+        self.assertEqual(
+            query[1]['o'],
+            'plone.app.querystring.operation.date.lessThanRelativeDate'
+        )
+        self.assertTrue(query[1]['v'], 5)
+
+        self.assertEqual(query[2]['i'], 'expires')
+        self.assertEqual(
+            query[2]['o'],
+            'plone.app.querystring.operation.date.afterToday'
+        )
+        self.assertTrue('v' not in query[2].keys())
+
+        self.assertEqual(query[3]['i'], 'modified')
+        self.assertEqual(
+            query[3]['o'],
+            'plone.app.querystring.operation.date.beforeToday'
+        )
+        self.assertTrue('v' not in query[3].keys())
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATDateCriteriaExactDay(self):
+        #time1 = DateTime()
+        # 5 days ago:
+        crit = self.add_criterion('created', 'ATFriendlyDateCriteria', 5)
+        crit.setOperation('within_day')
+        crit.setDateRange('-')
+        # 5 days from now:
+        crit = self.add_criterion('effective', 'ATFriendlyDateCriteria', 5)
+        crit.setOperation('within_day')
+        crit.setDateRange('+')
+        # past or future does not matter if the day is today.
+        # today minus
+        crit = self.add_criterion('expires', 'ATFriendlyDateCriteria', 0)
+        crit.setOperation('within_day')
+        crit.setDateRange('-')
+        # today plus
+        crit = self.add_criterion('modified', 'ATFriendlyDateCriteria', 0)
+        crit.setOperation('within_day')
+        crit.setDateRange('+')
+
+        self.run_migration()
+        query = self.portal.topic.getRawQuery()
+        time2 = DateTime()
+        self.assertEqual(len(query), 4)
+
+        self.assertEqual(query[0]['i'], 'created')
+        self.assertEqual(
+            query[0]['o'],
+            'plone.app.querystring.operation.date.between'
+        )
+        self.assertEqual(
+            query[0]['v'],
+            ((time2 - 5).earliestTime(), (time2 - 5).latestTime())
+        )
+
+        self.assertEqual(query[1]['i'], 'effective')
+        self.assertEqual(
+            query[1]['o'],
+            'plone.app.querystring.operation.date.between'
+        )
+        self.assertEqual(
+            query[1]['v'],
+            ((time2 + 5).earliestTime(), (time2 + 5).latestTime())
+        )
+
+        self.assertEqual(query[2]['i'], 'expires')
+        self.assertEqual(
+            query[2]['o'],
+            'plone.app.querystring.operation.date.today'
+        )
+        self.assertFalse('v' in query[2].keys())
+
+        self.assertEqual(query[3]['i'], 'modified')
+        self.assertEqual(
+            query[3]['o'],
+            'plone.app.querystring.operation.date.today'
+        )
+        self.assertFalse('v' in query[3].keys())
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATCurrentAuthorCriterion(self):
+        self.add_criterion('Creator', 'ATCurrentAuthorCriterion')
+        self.run_migration()
+        self.assertEqual(
+            self.portal.topic.getRawQuery(),
+            [{'i': 'Creator',
+              'o': 'plone.app.querystring.operation.string.currentUser',
+              'v': 'admin'}]
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATListCriterion(self):
+        # The new-style queries do not currently offer the possibility
+        # to choose if the given values should be joined with 'or' or
+        # 'and'.  Default is 'or'.
+        crit = self.add_criterion('Subject', 'ATListCriterion', ('foo', 'bar'))
+        crit.setOperator('or')
+        # Note: this could have been an ATPortalTypeCriterion too:
+        crit = self.add_criterion(
+            'portal_type',
+            'ATListCriterion', ('Document', 'Folder')
+        )
+        crit.setOperator('and')
+
+        self.run_migration()
+        query = self.portal.topic.getRawQuery()
+        self.assertEqual(len(query), 2)
+        self.assertEqual(query[0],
+                         {'i': 'Subject',
+                          'o': 'plone.app.querystring.operation.selection.is',
+                          'v': ('foo', 'bar')})
+        self.assertEqual(query[1],
+                         {'i': 'portal_type',
+                          'o': 'plone.app.querystring.operation.selection.is',
+                          'v': ('Document', 'Folder')})
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATPathCriterion(self):
+        self.add_criterion('path', 'ATPathCriterion', self.portal.folder.UID())
+        self.run_migration()
+        self.assertEqual(self.portal.topic.getRawQuery(),
+                         [{'i': 'path',
+                           'o': 'plone.app.querystring.operation.string.path',
+                           'v': self.portal.folder.UID()}])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATPathCriterionNonRecursive(self):
+        # Topics supported non recursive search, so search at a specific
+        # depth.  New Collections do not support it.
+        crit = self.add_criterion(
+            'path',
+            'ATPathCriterion', self.portal.folder.UID()
+        )
+        crit.setRecurse(True)
+        self.run_migration()
+        self.assertEqual(self.portal.topic.getRawQuery(),
+                         [{'i': 'path',
+                           'o': 'plone.app.querystring.operation.string.path',
+                           'v': self.portal.folder.UID()}])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATPathCriterionDouble(self):
+        # Collections currently support only one path.
+        login(self.portal, 'admin')
+        self.portal.invokeFactory("Folder", "folder2", title="Folder 2")
+        self.add_criterion(
+            'path',
+            'ATPathCriterion',
+            [self.portal.folder.UID(), self.portal.folder2.UID()]
+        )
+        self.run_migration()
+        query = self.portal.topic.getRawQuery()
+        self.assertEqual(len(query), 1)
+        self.assertEqual(query[0]['i'], 'path')
+        self.assertEqual(
+            query[0]['o'],
+            'plone.app.querystring.operation.string.path'
+        )
+        # Which of the paths is taken is not defined.  This might
+        # depend on the sort order of the uids.
+        self.assertTrue(
+            query[0]['v'] in (self.portal.folder.UID(),
+                              self.portal.folder2.UID())
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATBooleanCriterion(self):
+        # Note that in standard Plone the boolean criterion is only
+        # defined for is_folderish and is_default_page.
+        crit = self.add_criterion('is_folderish', 'ATBooleanCriterion')
+        crit.setBool(True)
+        crit = self.add_criterion('is_default_page', 'ATBooleanCriterion')
+        crit.setBool(False)
+        self.run_migration()
+        query = self.portal.topic.getRawQuery()
+        self.assertEqual(len(query), 2)
+        self.assertEqual(
+            query[0],
+            {'i': 'is_folderish',
+             'o': 'plone.app.querystring.operation.boolean.isTrue'}
+        )
+        self.assertEqual(
+            query[1],
+            {'i': 'is_default_page',
+             'o': 'plone.app.querystring.operation.boolean.isFalse'}
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATDateRangeCriteria(self):
+        time1 = DateTime()
+        # Days in the past:
+        crit = self.add_criterion('created', 'ATDateRangeCriterion')
+        crit.setStart(time1 - 5)
+        crit.setEnd(time1 - 3)
+        # Past and future:
+        crit = self.add_criterion('effective', 'ATDateRangeCriterion')
+        crit.setStart(time1 - 2)
+        crit.setEnd(time1 + 2)
+        # Days in the future:
+        crit = self.add_criterion('expires', 'ATDateRangeCriterion')
+        crit.setStart(time1 + 3)
+        crit.setEnd(time1 + 5)
+
+        self.run_migration()
+        query = self.portal.topic.getRawQuery()
+        self.assertEqual(len(query), 3)
+
+        self.assertEqual(query[0]['i'], 'created')
+        self.assertEqual(
+            query[0]['o'],
+            'plone.app.querystring.operation.date.between'
+        )
+        self.assertEqual(query[0]['v'], (time1 - 5, time1 - 3))
+
+        self.assertEqual(query[1]['i'], 'effective')
+        self.assertEqual(
+            query[1]['o'],
+            'plone.app.querystring.operation.date.between'
+        )
+        self.assertEqual(query[1]['v'], (time1 - 2, time1 + 2))
+
+        self.assertEqual(query[2]['i'], 'expires')
+        self.assertEqual(
+            query[2]['o'],
+            'plone.app.querystring.operation.date.between'
+        )
+        self.assertEqual(query[2]['v'], (time1 + 3, time1 + 5))
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATPortalTypeCriterion(self):
+        self.add_criterion(
+            'portal_type',
+            'ATPortalTypeCriterion', ('Document', 'Folder')
+        )
+        self.run_migration()
+        query = self.portal.topic.getRawQuery()
+        self.assertEqual(
+            query,
+            [{'i': 'portal_type',
+              'o': 'plone.app.querystring.operation.selection.is',
+              'v': ('Document', 'Folder')}]
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATPortalTypeCriterionOfTopic(self):
+        # We migrate Topics to Collections, so we should update
+        # criterions that search for Topics.
+        self.add_criterion(
+            'portal_type',
+            'ATPortalTypeCriterion', ('Topic',)
+        )
+        self.run_migration()
+        query = self.portal.topic.getRawQuery()
+        self.assertEqual(query,
+                         [{'i': 'portal_type',
+                           'o': 'plone.app.querystring.operation.selection.is',
+                           'v': ('Collection',)}])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSelectionCriterion(self):
+        # The new-style queries do not currently offer the possibility
+        # to choose if the given values should be joined with 'or' or
+        # 'and'.  Default is 'or'.
+        crit = self.add_criterion(
+            'Subject',
+            'ATSelectionCriterion',
+            ('foo', 'bar')
+        )
+        crit.setOperator('or')
+        # Note: this could have been an ATPortalTypeCriterion too:
+        # Note that we check that Topic is turned into Collection too.
+        crit = self.add_criterion(
+            'portal_type',
+            'ATSelectionCriterion',
+            ('Document', 'Topic')
+        )
+        crit.setOperator('and')
+
+        self.run_migration()
+        query = self.portal.topic.getRawQuery()
+        self.assertEqual(len(query), 2)
+        self.assertEqual(query[0],
+                         {'i': 'Subject',
+                          'o': 'plone.app.querystring.operation.selection.is',
+                          'v': ('foo', 'bar')})
+        self.assertEqual(query[1],
+                         {'i': 'portal_type',
+                          'o': 'plone.app.querystring.operation.selection.is',
+                          'v': ('Document', 'Collection')})
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSelectionCriterionForTypeTitle(self):
+        # 'portal_type' is the object id of the FTI in portal_types.
+        # 'Type' is the title of that object.
+        # For example:
+        # - portal_type 'Document' has Type 'Page'.
+        # - portal_type 'Topic' has Type 'Collection (old)'.
+        # Type is not enabled as criterion index by default, so we
+        # want to migrate to a portal_type criterion instead.
+        self.add_criterion('Type', 'ATSelectionCriterion', ('Page', 'Folder'))
+        self.run_migration()
+        query = self.portal.topic.getRawQuery()
+        self.assertEqual(query,
+                         [{'i': 'portal_type',
+                           'o': 'plone.app.querystring.operation.selection.is',
+                           'v': ('Document', 'Folder')}])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATReferenceCriterion(self):
+        # Note: the new criterion is disabled by default.  Also, it
+        # needs the _referenceIs function in the plone.app.querystring
+        # queryparser and that function is not defined.
+        self.add_criterion(
+            'getRawRelatedItems',
+            'ATReferenceCriterion',
+            self.portal.folder.UID()
+        )
+        self.run_migration()
+        # TODO re-enable this check when the queryparser works.
+        # self.assertEqual(
+        #     self.portal.topic.getRawQuery(),
+        #     [{'i': 'getRawRelatedItems',
+        #       'o': 'plone.app.querystring.operation.reference.is',
+        #       'v': (portal.folder.UID(),)}]
+        # )
+
+        # Check that the resulting query does not give an error.
+        # self.portal.topic.getQuery()
+
+    def test_ATRelativePathCriterion(self):
+        crit = self.add_criterion(
+            'path',
+            'ATRelativePathCriterion'
+        )
+        crit.setRelativePath('../folder')
+        self.run_migration()
+        self.assertEqual(
+            self.portal.topic.getRawQuery(),
+            [{'i': 'path',
+              'o': 'plone.app.querystring.operation.string.relativePath',
+              'v': '../folder'}]
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATRelativePathCriterionNonRecursive(self):
+        # Topics supported non recursive search, so search at a specific
+        # depth.  New Collections do not support it.
+        crit = self.add_criterion('path', 'ATRelativePathCriterion')
+        crit.setRelativePath('../folder')
+        crit.setRecurse(True)
+        self.run_migration()
+        self.assertEqual(
+            self.portal.topic.getRawQuery(),
+            [{'i': 'path',
+              'o': 'plone.app.querystring.operation.string.relativePath',
+              'v': '../folder'}])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSimpleIntCriterion(self):
+        self.add_criterion('getObjPositionInParent', 'ATSimpleIntCriterion', 7)
+        self.run_migration()
+        self.assertEqual(self.portal.topic.getRawQuery(),
+                         [{'i': 'getObjPositionInParent',
+                           'o': 'plone.app.querystring.operation.int.is',
+                           'v': 7}])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSimpleIntCriterionMinimum(self):
+        crit = self.add_criterion(
+            'getObjPositionInParent',
+            'ATSimpleIntCriterion', 6
+        )
+        crit.setDirection('min')
+        self.run_migration()
+        self.assertEqual(
+            self.portal.topic.getRawQuery(),
+            [{'i': 'getObjPositionInParent',
+              'o': 'plone.app.querystring.operation.int.largerThan',
+              'v': 6}]
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSimpleIntCriterionMaximum(self):
+        crit = self.add_criterion(
+            'getObjPositionInParent',
+            'ATSimpleIntCriterion',
+            5
+        )
+        crit.setDirection('max')
+        self.run_migration()
+        self.assertEqual(
+            self.portal.topic.getRawQuery(),
+            [{'i': 'getObjPositionInParent',
+              'o': 'plone.app.querystring.operation.int.lessThan',
+              'v': 5}]
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSimpleIntCriterionBetween(self):
+        # This is not supported.
+        crit = self.add_criterion(
+            'getObjPositionInParent',
+            'ATSimpleIntCriterion',
+            4
+        )
+        crit.setDirection('min:max')
+        crit.setValue2(8)
+        self.run_migration()
+        self.assertEqual(self.portal.topic.getRawQuery(), [])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSortCriterion(self):
+        self.add_criterion('modified', 'ATSortCriterion')
+        self.run_migration()
+        self.assertEqual(self.portal.topic.getSort_on(), 'modified')
+        self.assertEqual(self.portal.topic.getSort_reversed(), False)
+        self.assertEqual(self.portal.topic.getRawQuery(), [])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSortCriterionReversed(self):
+        crit = self.add_criterion('created', 'ATSortCriterion')
+        crit.setReversed(True)
+        self.run_migration()
+        self.assertEqual(self.portal.topic.getSort_on(), 'created')
+        self.assertEqual(self.portal.topic.getSort_reversed(), True)
+        self.assertEqual(self.portal.topic.getRawQuery(), [])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-11-07T09:54:42+01:00
Author: Jens W. Klein (jensens) <jens@bluedynamics.com>
Commit: https://github.com/plone/plone.app.contenttypes/commit/5d4bbd78e1cc65c2603831f7e1f6abde11c2f2ea

ops pdb. also sorted imports

Files changed:
M plone/app/contenttypes/migration/topics.py

diff --git a/plone/app/contenttypes/migration/topics.py b/plone/app/contenttypes/migration/topics.py
index 370a121..b2d8b70 100644
--- a/plone/app/contenttypes/migration/topics.py
+++ b/plone/app/contenttypes/migration/topics.py
@@ -1,9 +1,7 @@
 # -*- coding: utf-8 -*-
-import logging
-
 from DateTime import DateTime
-from Products.CMFCore.utils import getToolByName
 from Products.CMFCore.interfaces._content import IFolderish
+from Products.CMFCore.utils import getToolByName
 from Products.contentmigration.archetypes import InplaceATFolderMigrator
 from Products.contentmigration.archetypes import InplaceATItemMigrator
 from Products.contentmigration.walker import CustomQueryWalker
@@ -12,6 +10,8 @@
 from zope.component import getUtility
 from zope.dottedname.resolve import resolve
 
+import logging
+
 logger = logging.getLogger('plone.app.collection')
 prefix = "plone.app.querystring"
 
@@ -413,7 +413,6 @@ class ATSimpleIntCriterionConverter(CriterionConverter):
 
     def get_operation(self, value, index, criterion):
         # Get dotted operation method.
-        import pdb;pdb.set_trace()
         direction = value.get('range')
         if not direction:
             code = 'is'


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-11-07T09:54:43+01:00
Author: Maurits van Rees (mauritsvanrees) <maurits@vanrees.org>
Commit: https://github.com/plone/plone.app.contenttypes/commit/8e18eca79bead43b682845a836a2384ad5071df2

No longer need to checkout p.a.querystring for this branch.

Files changed:
M buildout.cfg

diff --git a/buildout.cfg b/buildout.cfg
index cb7da75..ff66b9f 100644
--- a/buildout.cfg
+++ b/buildout.cfg
@@ -1,7 +1,2 @@
 [buildout]
 extends = test-plone-4.3.x.cfg
-
-auto-checkout +=
-    plone.app.querystring
-  
-


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-11-07T09:54:43+01:00
Author: Maurits van Rees (mauritsvanrees) <maurits@vanrees.org>
Commit: https://github.com/plone/plone.app.contenttypes/commit/8b2fee0ea9d055c35a6da15f1b6c35b6f9ef507a

minor pep8 in topic migration

Files changed:
M plone/app/contenttypes/migration/topics.py

diff --git a/plone/app/contenttypes/migration/topics.py b/plone/app/contenttypes/migration/topics.py
index b2d8b70..68a33d7 100644
--- a/plone/app/contenttypes/migration/topics.py
+++ b/plone/app/contenttypes/migration/topics.py
@@ -139,9 +139,9 @@ def get_valid_operation(self, registry, index, value, criterion):
         key = '%s.field.%s.operations' % (prefix, index)
         operations = registry.get(key)
         operation = self.get_operation(value, index, criterion)
-        if not operation in operations:
+        if operation not in operations:
             operation = self.get_alt_operation(value, index, criterion)
-            if not operation in operations:
+            if operation not in operations:
                 return
         if self.is_operation_valid(registry, operation):
             return operation
@@ -234,7 +234,7 @@ def __call__(self, formquery, criterion, registry):
         operations = registry.get(key)
 
         def add_row(operation, value=None):
-            if not operation in operations:
+            if operation not in operations:
                 # TODO just ignore it?
                 raise ValueError(INVALID_OPERATION % (operation, criterion))
             if not self.is_operation_valid(registry, operation):


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-11-07T09:54:43+01:00
Author: Maurits van Rees (mauritsvanrees) <maurits@vanrees.org>
Commit: https://github.com/plone/plone.app.contenttypes/commit/1ce20809cfccc14316435072591d31b39ed9336e

fix topic logger name

Files changed:
M plone/app/contenttypes/migration/topics.py

diff --git a/plone/app/contenttypes/migration/topics.py b/plone/app/contenttypes/migration/topics.py
index 68a33d7..85fb123 100644
--- a/plone/app/contenttypes/migration/topics.py
+++ b/plone/app/contenttypes/migration/topics.py
@@ -12,7 +12,7 @@
 
 import logging
 
-logger = logging.getLogger('plone.app.collection')
+logger = logging.getLogger(__name__)
 prefix = "plone.app.querystring"
 
 INVALID_OPERATION = 'Invalid operation %s for criterion: %s'


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-11-07T09:54:43+01:00
Author: Maurits van Rees (mauritsvanrees) <maurits@vanrees.org>
Commit: https://github.com/plone/plone.app.contenttypes/commit/99038fb9dea53940230e2f4026aaa341726f8b51

Add Topic upgrade notes

Files changed:
M plone/app/contenttypes/migration/topics.py

diff --git a/plone/app/contenttypes/migration/topics.py b/plone/app/contenttypes/migration/topics.py
index 85fb123..13d2aea 100644
--- a/plone/app/contenttypes/migration/topics.py
+++ b/plone/app/contenttypes/migration/topics.py
@@ -1,4 +1,33 @@
 # -*- coding: utf-8 -*-
+
+"""
+TODO/plans for topic migration.
+
+- Fix the TODO items embedded in the code below, or document missing
+  features.  This may need additions in plone.app.querystring, or
+  needs checking to see if features are already supported in there
+  meanwhile.
+
+- When needed, register a FolderishCollection type: Folder with
+  Collection behavior enabled.  We can use this to migrate Topics with
+  sub topics.
+
+- See if we can get migrations to work for anything we can come up with:
+
+  - Plain AT Topics.
+
+  - AT Topics with sub Topics inside.
+
+  - plone.app.collection 1.x Archetypes
+
+  - plone.app.collection 1.x Archetypes folderish (in use by Maurits,
+    see p.a.collection branch maurits-upgrade-path, where this
+    migration code actually originated)
+
+  - plone.app.collection 2.x Dexterity
+
+"""
+
 from DateTime import DateTime
 from Products.CMFCore.interfaces._content import IFolderish
 from Products.CMFCore.utils import getToolByName


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-11-07T09:54:43+01:00
Author: Maurits van Rees (mauritsvanrees) <maurits@vanrees.org>
Commit: https://github.com/plone/plone.app.contenttypes/commit/405453519c1155023c5b793fba1bc3c16e64a06a

Support migrating non-recursive path queries.

Files changed:
M plone/app/contenttypes/migration/topics.py
M plone/app/contenttypes/tests/test_migration_topic.py

diff --git a/plone/app/contenttypes/migration/topics.py b/plone/app/contenttypes/migration/topics.py
index 13d2aea..4032e3e 100644
--- a/plone/app/contenttypes/migration/topics.py
+++ b/plone/app/contenttypes/migration/topics.py
@@ -350,16 +350,19 @@ class ATPathCriterionConverter(CriterionConverter):
     operator_code = 'string.path'
 
     def get_query_value(self, value, index, criterion):
-        if not criterion.Recurse():
-            logger.warn("Cannot handle non-recursive path search. "
-                        "Allowing recursive search. %r", value)
         raw = criterion.getRawValue()
         if not raw:
             return
         if len(raw) > 1:
             logger.warn("Multiple paths in query. Using only the first. %r",
                         value['query'])
-        return raw[0]
+        path = raw[0]
+        # Is this a recursive query?  Could check depth in the value
+        # actually, but Recurse is the canonical way.  Also, the only
+        # possible values for depth are -1 and 1.
+        if not criterion.Recurse():
+            path += '::1'
+        return path
 
 
 class ATBooleanCriterionConverter(CriterionConverter):
diff --git a/plone/app/contenttypes/tests/test_migration_topic.py b/plone/app/contenttypes/tests/test_migration_topic.py
index 4d95d4e..6c7682b 100644
--- a/plone/app/contenttypes/tests/test_migration_topic.py
+++ b/plone/app/contenttypes/tests/test_migration_topic.py
@@ -336,7 +336,10 @@ def test_ATListCriterion(self):
         self.portal.topic.getQuery()
 
     def test_ATPathCriterion(self):
-        self.add_criterion('path', 'ATPathCriterion', self.portal.folder.UID())
+        crit = self.add_criterion(
+            'path',
+            'ATPathCriterion', self.portal.folder.UID())
+        crit.setRecurse(True)
         self.run_migration()
         self.assertEqual(self.portal.topic.getRawQuery(),
                          [{'i': 'path',
@@ -347,18 +350,19 @@ def test_ATPathCriterion(self):
         self.portal.topic.getQuery()
 
     def test_ATPathCriterionNonRecursive(self):
-        # Topics supported non recursive search, so search at a specific
-        # depth.  New Collections do not support it.
+        # Topics supported non recursive search, so search at a
+        # specific depth of 1.  At first, new Collections did not
+        # support it.  But since plone.app.querystring 1.1.0 it works.
         crit = self.add_criterion(
             'path',
             'ATPathCriterion', self.portal.folder.UID()
         )
-        crit.setRecurse(True)
+        crit.setRecurse(False)
         self.run_migration()
         self.assertEqual(self.portal.topic.getRawQuery(),
                          [{'i': 'path',
                            'o': 'plone.app.querystring.operation.string.path',
-                           'v': self.portal.folder.UID()}])
+                           'v': self.portal.folder.UID() + '::1'}])
 
         # Check that the resulting query does not give an error.
         self.portal.topic.getQuery()
@@ -367,11 +371,12 @@ def test_ATPathCriterionDouble(self):
         # Collections currently support only one path.
         login(self.portal, 'admin')
         self.portal.invokeFactory("Folder", "folder2", title="Folder 2")
-        self.add_criterion(
+        crit = self.add_criterion(
             'path',
             'ATPathCriterion',
             [self.portal.folder.UID(), self.portal.folder2.UID()]
         )
+        crit.setRecurse(True)
         self.run_migration()
         query = self.portal.topic.getRawQuery()
         self.assertEqual(len(query), 1)


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-11-07T09:54:43+01:00
Author: Maurits van Rees (mauritsvanrees) <maurits@vanrees.org>
Commit: https://github.com/plone/plone.app.contenttypes/commit/d4f2c6fa61023f5988fa8d16de0fbb6c6ac61340

Support migrating multiple path queries.

Files changed:
M plone/app/contenttypes/migration/topics.py
M plone/app/contenttypes/tests/test_migration_topic.py

diff --git a/plone/app/contenttypes/migration/topics.py b/plone/app/contenttypes/migration/topics.py
index 4032e3e..45ca59b 100644
--- a/plone/app/contenttypes/migration/topics.py
+++ b/plone/app/contenttypes/migration/topics.py
@@ -175,6 +175,13 @@ def get_valid_operation(self, registry, index, value, criterion):
         if self.is_operation_valid(registry, operation):
             return operation
 
+    def add_to_formquery(self, formquery, index, operation, query_value):
+        row = {'i': index,
+               'o': operation}
+        if query_value is not None:
+            row['v'] = query_value
+        formquery.append(row)
+
     def __call__(self, formquery, criterion, registry):
         criteria = criterion.getCriteriaItems()
         if not criteria:
@@ -211,11 +218,7 @@ def __call__(self, formquery, criterion, registry):
             query_value = self.get_query_value(value, index, criterion)
 
             # Add a row to the form query.
-            row = {'i': index,
-                   'o': operation}
-            if query_value is not None:
-                row['v'] = query_value
-            formquery.append(row)
+            self.add_to_formquery(formquery, index, operation, query_value)
 
 
 class ATDateCriteriaConverter(CriterionConverter):
@@ -356,13 +359,22 @@ def get_query_value(self, value, index, criterion):
         if len(raw) > 1:
             logger.warn("Multiple paths in query. Using only the first. %r",
                         value['query'])
-        path = raw[0]
         # Is this a recursive query?  Could check depth in the value
         # actually, but Recurse is the canonical way.  Also, the only
         # possible values for depth are -1 and 1.
         if not criterion.Recurse():
-            path += '::1'
-        return path
+            for index, path in enumerate(raw):
+                raw[index] = path + '::1'
+        return raw
+
+    def add_to_formquery(self, formquery, index, operation, query_value):
+        if query_value is None:
+            return
+        for value in query_value:
+            row = {'i': index,
+                   'o': operation,
+                   'v': value}
+            formquery.append(row)
 
 
 class ATBooleanCriterionConverter(CriterionConverter):
diff --git a/plone/app/contenttypes/tests/test_migration_topic.py b/plone/app/contenttypes/tests/test_migration_topic.py
index 6c7682b..1628dbf 100644
--- a/plone/app/contenttypes/tests/test_migration_topic.py
+++ b/plone/app/contenttypes/tests/test_migration_topic.py
@@ -359,7 +359,9 @@ def test_ATPathCriterionNonRecursive(self):
         )
         crit.setRecurse(False)
         self.run_migration()
-        self.assertEqual(self.portal.topic.getRawQuery(),
+        query = self.portal.topic.getRawQuery()
+        self.assertEqual(len(query), 1)
+        self.assertEqual(query,
                          [{'i': 'path',
                            'o': 'plone.app.querystring.operation.string.path',
                            'v': self.portal.folder.UID() + '::1'}])
@@ -367,8 +369,9 @@ def test_ATPathCriterionNonRecursive(self):
         # Check that the resulting query does not give an error.
         self.portal.topic.getQuery()
 
-    def test_ATPathCriterionDouble(self):
-        # Collections currently support only one path.
+    def test_ATPathCriterionMultiRecursive(self):
+        # Collections support multiple paths since
+        # plone.app.querystring 1.2.0.
         login(self.portal, 'admin')
         self.portal.invokeFactory("Folder", "folder2", title="Folder 2")
         crit = self.add_criterion(
@@ -379,18 +382,41 @@ def test_ATPathCriterionDouble(self):
         crit.setRecurse(True)
         self.run_migration()
         query = self.portal.topic.getRawQuery()
-        self.assertEqual(len(query), 1)
-        self.assertEqual(query[0]['i'], 'path')
-        self.assertEqual(
-            query[0]['o'],
-            'plone.app.querystring.operation.string.path'
-        )
-        # Which of the paths is taken is not defined.  This might
-        # depend on the sort order of the uids.
-        self.assertTrue(
-            query[0]['v'] in (self.portal.folder.UID(),
-                              self.portal.folder2.UID())
+        self.assertEqual(len(query), 2)
+        self.assertEqual(query[0],
+                         {'i': 'path',
+                          'o': 'plone.app.querystring.operation.string.path',
+                          'v': self.portal.folder.UID()})
+        self.assertEqual(query[1],
+                         {'i': 'path',
+                          'o': 'plone.app.querystring.operation.string.path',
+                          'v': self.portal.folder2.UID()})
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATPathCriterionMultiNonRecursive(self):
+        # Collections support multiple paths since
+        # plone.app.querystring 1.2.0.
+        login(self.portal, 'admin')
+        self.portal.invokeFactory("Folder", "folder2", title="Folder 2")
+        crit = self.add_criterion(
+            'path',
+            'ATPathCriterion',
+            [self.portal.folder.UID(), self.portal.folder2.UID()]
         )
+        crit.setRecurse(False)
+        self.run_migration()
+        query = self.portal.topic.getRawQuery()
+        self.assertEqual(len(query), 2)
+        self.assertEqual(query[0],
+                         {'i': 'path',
+                          'o': 'plone.app.querystring.operation.string.path',
+                          'v': self.portal.folder.UID() + '::1'})
+        self.assertEqual(query[1],
+                         {'i': 'path',
+                          'o': 'plone.app.querystring.operation.string.path',
+                          'v': self.portal.folder2.UID() + '::1'})
 
         # Check that the resulting query does not give an error.
         self.portal.topic.getQuery()


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-11-07T09:54:43+01:00
Author: Maurits van Rees (mauritsvanrees) <maurits@vanrees.org>
Commit: https://github.com/plone/plone.app.contenttypes/commit/3356948d46ea8a18bc0f5bda3123bf1ee154f2c8

Removed unneeded commented out line.

Files changed:
M plone/app/contenttypes/tests/test_migration_topic.py

diff --git a/plone/app/contenttypes/tests/test_migration_topic.py b/plone/app/contenttypes/tests/test_migration_topic.py
index 1628dbf..062f002 100644
--- a/plone/app/contenttypes/tests/test_migration_topic.py
+++ b/plone/app/contenttypes/tests/test_migration_topic.py
@@ -233,7 +233,6 @@ def test_ATDateCriteriaFuture(self):
         self.portal.topic.getQuery()
 
     def test_ATDateCriteriaExactDay(self):
-        #time1 = DateTime()
         # 5 days ago:
         crit = self.add_criterion('created', 'ATFriendlyDateCriteria', 5)
         crit.setOperation('within_day')


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-11-07T09:54:43+01:00
Author: Maurits van Rees (mauritsvanrees) <maurits@vanrees.org>
Commit: https://github.com/plone/plone.app.contenttypes/commit/65b9b65dc1559d10d73766fb00f5942a01624db7

Forgot to remove warning about multiple paths.

Files changed:
M plone/app/contenttypes/migration/topics.py

diff --git a/plone/app/contenttypes/migration/topics.py b/plone/app/contenttypes/migration/topics.py
index 45ca59b..ff1f02e 100644
--- a/plone/app/contenttypes/migration/topics.py
+++ b/plone/app/contenttypes/migration/topics.py
@@ -356,9 +356,6 @@ def get_query_value(self, value, index, criterion):
         raw = criterion.getRawValue()
         if not raw:
             return
-        if len(raw) > 1:
-            logger.warn("Multiple paths in query. Using only the first. %r",
-                        value['query'])
         # Is this a recursive query?  Could check depth in the value
         # actually, but Recurse is the canonical way.  Also, the only
         # possible values for depth are -1 and 1.


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-11-07T09:54:43+01:00
Author: Maurits van Rees (mauritsvanrees) <maurits@vanrees.org>
Commit: https://github.com/plone/plone.app.contenttypes/commit/b8cd1a82b984df7a0a18eaf101d99e88f3cee305

Make slightly less complex, trying to please flake8.

Files changed:
M plone/app/contenttypes/migration/topics.py

diff --git a/plone/app/contenttypes/migration/topics.py b/plone/app/contenttypes/migration/topics.py
index ff1f02e..92ed6b6 100644
--- a/plone/app/contenttypes/migration/topics.py
+++ b/plone/app/contenttypes/migration/topics.py
@@ -289,7 +289,7 @@ def add_row(operation, value=None):
             new_operation = "%s.operation.date.between" % prefix
             add_row(new_operation, date_range)
             return
-        elif operation == 'more':
+        if operation == 'more':
             if value != 0:
                 new_operation = ("{0}.operation.date."
                                  "largerThanRelativeDate".format(prefix))
@@ -299,7 +299,7 @@ def add_row(operation, value=None):
                 new_operation = "{0}.operation.date.afterToday".format(prefix)
                 add_row(new_operation)
                 return
-        elif operation == 'less':
+        if operation == 'less':
             if value != 0:
                 new_operation = ("{0}.operation.date."
                                  "lessThanRelativeDate".format(prefix))
@@ -407,7 +407,8 @@ def __call__(self, formquery, criterion, registry):
                 continue
             self.is_index_enabled(registry, fieldname)
             # Get the operation method.
-            operation = self.get_valid_operation(registry, fieldname, value, criterion)
+            operation = self.get_valid_operation(
+                registry, fieldname, value, criterion)
             if not operation:
                 logger.error(INVALID_OPERATION % (operation, criterion))
                 # TODO: raise an Exception?


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-11-07T10:11:34+01:00
Author: Philip Bauer (pbauer) <bauer@starzel.de>
Commit: https://github.com/plone/plone.app.contenttypes/commit/e2049179df9b19d4e2489cd9d2fd9cdd5b2f8e85

Migrate to Dexterity-Collections (not Archetypes-Collections from plone.app.collection)

Files changed:
M plone/app/contenttypes/migration/topics.py

diff --git a/plone/app/contenttypes/migration/topics.py b/plone/app/contenttypes/migration/topics.py
index 92ed6b6..8428fd2 100644
--- a/plone/app/contenttypes/migration/topics.py
+++ b/plone/app/contenttypes/migration/topics.py
@@ -31,12 +31,16 @@
 from DateTime import DateTime
 from Products.CMFCore.interfaces._content import IFolderish
 from Products.CMFCore.utils import getToolByName
-from Products.contentmigration.archetypes import InplaceATFolderMigrator
-from Products.contentmigration.archetypes import InplaceATItemMigrator
+from Products.contentmigration.inplace import InplaceCMFFolderMigrator
+from Products.contentmigration.inplace import InplaceCMFItemMigrator
 from Products.contentmigration.walker import CustomQueryWalker
+from plone.app.contenttypes.behaviors.collection import ICollection
+from plone.app.contenttypes.migration.migration import ReferenceMigrator
 from plone.app.querystring.interfaces import IQuerystringRegistryReader
 from plone.registry.interfaces import IRegistry
+from plone.uuid.interfaces import IMutableUUID
 from zope.component import getUtility
+from zope.component import queryAdapter
 from zope.dottedname.resolve import resolve
 
 import logging
@@ -45,7 +49,7 @@
 prefix = "plone.app.querystring"
 
 INVALID_OPERATION = 'Invalid operation %s for criterion: %s'
-PROFILE_ID = 'profile-plone.app.collection:default'
+# PROFILE_ID = 'profile-plone.app.collection:default'
 
 
 def format_date(value):
@@ -478,7 +482,7 @@ def get_query_value(self, value, index, criterion):
         return value['query']
 
 
-class TopicMigrator(InplaceATFolderMigrator):
+class TopicMigrator(InplaceCMFItemMigrator, ReferenceMigrator):
     src_portal_type = 'Topic'
     src_meta_type = 'ATTopic'
     dst_portal_type = dst_meta_type = 'Collection'
@@ -510,7 +514,8 @@ def last_migrate_layout(self):
             self.new.setLayout('tabular_view')
             return
 
-        layout = self.view_methods_mapping.get(self.old.getLayout())
+        old_layout = self.old.getLayout() or getattr(self.old, 'layout', None)
+        layout = self.view_methods_mapping.get(old_layout)
         if layout:
             self.new.setLayout(layout)
 
@@ -559,19 +564,35 @@ def migrate_criteria(self):
         """
         # The old Topic has boolean limitNumber and integer itemCount,
         # where the new Collection only has limit.
+        adapted = ICollection(self.new)
         if self.old.getLimitNumber():
-            self.new.setLimit(self.old.getItemCount())
+            adapted.limit = self.old.getItemCount()
+        adapted.customViewFields = self.old.getCustomViewFields()
 
-        # Get the old data stores by the beforeChange_criteria method.
+        # Get the old data stored by the beforeChange_criteria method.
         if self._collection_sort_reversed is not None:
-            self.new.setSort_reversed(self._collection_sort_reversed)
+            adapted.sort_reversed = self._collection_sort_reversed
         if self._collection_sort_on is not None:
-            self.new.setSort_on(self._collection_sort_on)
+            adapted.sort_on = self._collection_sort_on
         if self._collection_query is not None:
-            self.new.setQuery(self._collection_query)
+            adapted.query = self._collection_query
 
 
-class FolderishCollectionMigrator(InplaceATItemMigrator):
+    def migrate_atctmetadata(self):
+        field = self.old.getField('excludeFromNav')
+        self.new.exclude_from_nav = field.get(self.old)
+
+    def migrate_at_uuid(self):
+        """Migrate AT universal uid
+        """
+        uid = self.UID
+        if uid and queryAdapter(self.new, IMutableUUID):
+            IMutableUUID(self.new).set(str(uid))
+
+
+class FolderishCollectionMigrator(InplaceCMFFolderMigrator, ReferenceMigrator):
+    """stub Migration for ATTopic -> Folderish Collections
+    """
     src_portal_type = src_meta_type = 'Collection'
     dst_portal_type = dst_meta_type = 'Collection'
 
@@ -616,23 +637,18 @@ def run_propertiestool_step(context):
     context.runImportStepFromProfile(PROFILE_ID, 'propertiestool')
 
 
-def migrate_topics(context):
-    """Migrate ATContentTypes Topics to plone.app.collection Collections.
-
-    This can be used as upgrade step.
+def migrate_topics(portal):
+    """Migrate ATContentTypes Topics to plone.app.contenttypes Collections.
 
-    The new-style Collections might again get some changes later.
-    They may become folderish or dexterity items or dexterity
-    containers or a dexterity behavior.
+    This could also be used as upgrade step.
     """
-    site = getToolByName(context, 'portal_url').getPortalObject()
-    topic_walker = CustomQueryWalker(site, TopicMigrator)
     # Parse the registry to get allowed operations and pass it to the
     # migrator.
     reg = getUtility(IRegistry)
     reader = IQuerystringRegistryReader(reg)
     registry = reader.parseRegistry()
-    topic_walker.go(registry=registry)
+    walker = CustomQueryWalker(portal, TopicMigrator)(registry=registry)
+    return walker
 
 
 CONVERTERS = {


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-11-07T10:12:22+01:00
Author: Philip Bauer (pbauer) <bauer@starzel.de>
Commit: https://github.com/plone/plone.app.contenttypes/commit/ef83e97fc8e723296bbec6b3b9edaa170ba4a380

enable migration to DX-Collections in the form and fix tests to use dx-collections

Files changed:
M plone/app/contenttypes/migration/vocabularies.py
M plone/app/contenttypes/tests/test_migration.py
M plone/app/contenttypes/tests/test_migration_topic.py

diff --git a/plone/app/contenttypes/migration/vocabularies.py b/plone/app/contenttypes/migration/vocabularies.py
index 5c98154..338f143 100644
--- a/plone/app/contenttypes/migration/vocabularies.py
+++ b/plone/app/contenttypes/migration/vocabularies.py
@@ -6,12 +6,14 @@
 from Products.ATContentTypes.interfaces.image import IATImage
 from Products.ATContentTypes.interfaces.link import IATLink
 from Products.ATContentTypes.interfaces.news import IATNewsItem
+from Products.ATContentTypes.interfaces.topic import IATTopic
 from Products.CMFCore.utils import getToolByName
 from plone.app.blob.interfaces import IATBlobFile
 from plone.app.blob.interfaces import IATBlobImage
 from plone.app.contenttypes import _
 from plone.app.contenttypes.migration import migration
 from plone.app.contenttypes.migration.utils import isSchemaExtended
+from plone.app.contenttypes.migration.topics import migrate_topics
 from zope.interface import implements
 from zope.schema.interfaces import IVocabularyFactory
 from zope.schema.vocabulary import SimpleVocabulary
@@ -93,6 +95,13 @@
         'type_name': 'File',
         'old_meta_type': 'ATBlob',
     },
+    "Topic": {
+        'iface': IATTopic,
+        'migrator': migrate_topics,
+        'extended_fields': [],
+        'type_name': 'Collection',
+        'old_meta_type': 'ATTopic',
+    },
 }
 
 if HAS_APP_COLLECTION:
diff --git a/plone/app/contenttypes/tests/test_migration.py b/plone/app/contenttypes/tests/test_migration.py
index fd6c0fc..1769029 100644
--- a/plone/app/contenttypes/tests/test_migration.py
+++ b/plone/app/contenttypes/tests/test_migration.py
@@ -1139,6 +1139,7 @@ def test_migrate_xx_functions(self):
             migrate_folders,
             migrate_events,
         )
+        from plone.app.contenttypes.migration.topics import migrate_topics
 
         # create all content types
         self.portal.invokeFactory('Document', 'document')
@@ -1152,6 +1153,7 @@ def test_migrate_xx_functions(self):
         self.createATCTBlobNewsItem('blobnewsitem')
         self.portal.invokeFactory('Folder', 'folder')
         self.portal.invokeFactory('Event', 'event')
+        self.portal.invokeFactory('Topic', 'topic')
 
         # migrate all
         applyProfile(self.portal, 'plone.app.contenttypes:default')
@@ -1166,6 +1168,7 @@ def test_migrate_xx_functions(self):
         migrate_blobnewsitems(self.portal)
         migrate_folders(self.portal)
         migrate_events(self.portal)
+        migrate_topics(self.portal)
 
         # assertions
         cat = self.catalog
@@ -1174,7 +1177,7 @@ def test_migrate_xx_functions(self):
         dx_contents = cat(object_provides='plone.dexterity'
                           '.interfaces.IDexterityContent')
         self.assertEqual(len(at_contents), 0)
-        self.assertEqual(len(dx_contents), 11)
+        self.assertEqual(len(dx_contents), 12)
 
     def test_warning_for_uneditable_content(self):
         set_browserlayer(self.request)
diff --git a/plone/app/contenttypes/tests/test_migration_topic.py b/plone/app/contenttypes/tests/test_migration_topic.py
index 062f002..047f6b9 100644
--- a/plone/app/contenttypes/tests/test_migration_topic.py
+++ b/plone/app/contenttypes/tests/test_migration_topic.py
@@ -2,14 +2,16 @@
 from DateTime import DateTime
 from Products.CMFCore.utils import getToolByName
 from plone.app.contenttypes.migration.topics import migrate_topics
+from plone.app.contenttypes.behaviors.collection import ICollection
 from plone.app.contenttypes.testing import \
     PLONE_APP_CONTENTTYPES_MIGRATION_TESTING
+from plone.app.testing import applyProfile
 from plone.app.testing import login
 
 import unittest
 
 
-class MigrateToATContentTypesTest(unittest.TestCase):
+class MigrateTopicsIntegrationTest(unittest.TestCase):
 
     layer = PLONE_APP_CONTENTTYPES_MIGRATION_TESTING
 
@@ -30,7 +32,7 @@ def setUp(self):
         self.portal.invokeFactory("Folder", "folder", title="Folder")
 
     def run_migration(self):
-        migrate_topics(getToolByName(self.portal, 'portal_setup'))
+        migrate_topics(self.portal)
 
     def add_criterion(self, index, criterion, value=None):
         name = '%s_%s' % (index, criterion)
@@ -43,61 +45,59 @@ def add_criterion(self, index, criterion, value=None):
     def test_migrate_simple_topic(self):
         self.assertEqual(self.portal.topic.portal_type, 'Topic')
         self.assertEqual(self.portal.topic.getLayout(), 'atct_topic_view')
-        self.assertEqual(self.portal.topic.getAcquireCriteria(), False)
         self.assertEqual(self.portal.topic.getLimitNumber(), False)
         self.assertEqual(self.portal.topic.getItemCount(), 0)
         self.assertEqual(self.portal.topic.getCustomViewFields(), ('Title',))
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
+        new = ICollection(self.portal.topic)
         self.assertEqual(self.portal.topic.portal_type, 'Collection')
         self.assertEqual(self.portal.topic.getLayout(), 'standard_view')
-
-        # TODO: Check how to proceed with the missing method
-        # self.assertEqual(self.portal.topic.getAcquireCriteria(), False)
-        self.assertEqual(self.portal.topic.getSort_on(), 'sortable_title')
-        self.assertEqual(self.portal.topic.getSort_reversed(), False)
-        self.assertEqual(self.portal.topic.getLimit(), 1000)
-        self.assertEqual(self.portal.topic.getCustomViewFields(), ('Title',))
+        self.assertEqual(new.sort_on, None)
+        self.assertEqual(new.sort_reversed, None)
+        self.assertEqual(new.limit, 1000)
+        self.assertEqual(new.customViewFields, ('Title',))
 
     def test_migrate_topic_fields(self):
-        self.portal.topic.setAcquireCriteria(True)
         self.portal.topic.setText('<p>Hello</p>')
         self.portal.topic.setLimitNumber(True)
         self.portal.topic.setItemCount(42)
         self.portal.topic.setCustomViewFields(('Title', 'Type'))
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
+        new = ICollection(self.portal.topic)
         self.assertEqual(self.portal.topic.portal_type, 'Collection')
-
-        # TODO: Check how to proceed with the missing method
-        # self.assertEqual(self.portal.topic.getAcquireCriteria(), True)
-        self.assertEqual(self.portal.topic.getLimit(), 42)
-        self.assertEqual(self.portal.topic.getCustomViewFields(),
-                         ('Title', 'Type'))
+        self.assertEqual(new.limit, 42)
+        self.assertEqual(new.customViewFields, ('Title', 'Type'))
 
     def test_migrate_layout(self):
-        self.portal.topic.setAcquireCriteria(True)
         self.portal.topic.setLayout('folder_summary_view')
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
         self.assertEqual(self.portal.topic.getLayout(), 'summary_view')
 
     def test_migrate_customView(self):
-        self.portal.topic.setAcquireCriteria(True)
         self.portal.topic.setCustomView(True)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
         self.assertEqual(self.portal.topic.getLayout(), 'tabular_view')
 
+    @unittest.skip("Only works when migrating to folderish collections")
     def test_migrate_nested_topic(self):
         self.portal.portal_types.Topic.filter_content_types = False
         self.portal.portal_types.Collection.filter_content_types = False
         self.portal.topic.invokeFactory("Topic", "subtopic", title="Sub Topic")
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
         self.assertEqual(self.portal.topic.portal_type, 'Collection')
         self.assertEqual(self.portal.topic.subtopic.portal_type, 'Collection')
 
     def test_ATSimpleStringCriterion(self):
         self.add_criterion('SearchableText', 'ATSimpleStringCriterion', 'bar')
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
         self.assertEqual(
-            self.portal.topic.getRawQuery(),
+            self.portal.topic.query,
             [{'i': 'SearchableText',
               'o': 'plone.app.querystring.operation.string.contains',
               'v': 'bar'}]
@@ -112,17 +112,15 @@ def test_ATSimpleStringCriterionToSelection(self):
             'review_state',
             'ATSimpleStringCriterion', 'published'
         )
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
         self.assertEqual(
-            self.portal.topic.getRawQuery(),
+            self.portal.topic.query,
             [{'i': 'review_state',
               'o': 'plone.app.querystring.operation.selection.is',
               'v': 'published'}]
         )
 
-        # Check that the resulting query does not give an error.
-        self.portal.topic.getQuery()
-
     def test_ATDateCriteriaPast(self):
         # More than 5 days in the past:
         crit = self.add_criterion('created', 'ATFriendlyDateCriteria', 5)
@@ -144,8 +142,9 @@ def test_ATDateCriteriaPast(self):
         crit.setOperation('less')
         crit.setDateRange('-')
 
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        query = self.portal.topic.getRawQuery()
+        query = self.portal.topic.query
         self.assertEqual(len(query), 4)
 
         self.assertEqual(query[0]['i'], 'created')
@@ -197,8 +196,9 @@ def test_ATDateCriteriaFuture(self):
         crit.setOperation('less')
         crit.setDateRange('+')
 
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        query = self.portal.topic.getRawQuery()
+        query = self.portal.topic.query
         self.assertEqual(len(query), 4)
 
         self.assertEqual(query[0]['i'], 'created')
@@ -251,8 +251,9 @@ def test_ATDateCriteriaExactDay(self):
         crit.setOperation('within_day')
         crit.setDateRange('+')
 
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        query = self.portal.topic.getRawQuery()
+        query = self.portal.topic.query
         time2 = DateTime()
         self.assertEqual(len(query), 4)
 
@@ -295,9 +296,10 @@ def test_ATDateCriteriaExactDay(self):
 
     def test_ATCurrentAuthorCriterion(self):
         self.add_criterion('Creator', 'ATCurrentAuthorCriterion')
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
         self.assertEqual(
-            self.portal.topic.getRawQuery(),
+            self.portal.topic.query,
             [{'i': 'Creator',
               'o': 'plone.app.querystring.operation.string.currentUser',
               'v': 'admin'}]
@@ -319,8 +321,9 @@ def test_ATListCriterion(self):
         )
         crit.setOperator('and')
 
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        query = self.portal.topic.getRawQuery()
+        query = self.portal.topic.query
         self.assertEqual(len(query), 2)
         self.assertEqual(query[0],
                          {'i': 'Subject',
@@ -339,8 +342,9 @@ def test_ATPathCriterion(self):
             'path',
             'ATPathCriterion', self.portal.folder.UID())
         crit.setRecurse(True)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        self.assertEqual(self.portal.topic.getRawQuery(),
+        self.assertEqual(self.portal.topic.query,
                          [{'i': 'path',
                            'o': 'plone.app.querystring.operation.string.path',
                            'v': self.portal.folder.UID()}])
@@ -357,8 +361,9 @@ def test_ATPathCriterionNonRecursive(self):
             'ATPathCriterion', self.portal.folder.UID()
         )
         crit.setRecurse(False)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        query = self.portal.topic.getRawQuery()
+        query = self.portal.topic.query
         self.assertEqual(len(query), 1)
         self.assertEqual(query,
                          [{'i': 'path',
@@ -379,8 +384,9 @@ def test_ATPathCriterionMultiRecursive(self):
             [self.portal.folder.UID(), self.portal.folder2.UID()]
         )
         crit.setRecurse(True)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        query = self.portal.topic.getRawQuery()
+        query = self.portal.topic.query
         self.assertEqual(len(query), 2)
         self.assertEqual(query[0],
                          {'i': 'path',
@@ -405,8 +411,9 @@ def test_ATPathCriterionMultiNonRecursive(self):
             [self.portal.folder.UID(), self.portal.folder2.UID()]
         )
         crit.setRecurse(False)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        query = self.portal.topic.getRawQuery()
+        query = self.portal.topic.query
         self.assertEqual(len(query), 2)
         self.assertEqual(query[0],
                          {'i': 'path',
@@ -427,8 +434,9 @@ def test_ATBooleanCriterion(self):
         crit.setBool(True)
         crit = self.add_criterion('is_default_page', 'ATBooleanCriterion')
         crit.setBool(False)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        query = self.portal.topic.getRawQuery()
+        query = self.portal.topic.query
         self.assertEqual(len(query), 2)
         self.assertEqual(
             query[0],
@@ -459,8 +467,9 @@ def test_ATDateRangeCriteria(self):
         crit.setStart(time1 + 3)
         crit.setEnd(time1 + 5)
 
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        query = self.portal.topic.getRawQuery()
+        query = self.portal.topic.query
         self.assertEqual(len(query), 3)
 
         self.assertEqual(query[0]['i'], 'created')
@@ -492,8 +501,9 @@ def test_ATPortalTypeCriterion(self):
             'portal_type',
             'ATPortalTypeCriterion', ('Document', 'Folder')
         )
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        query = self.portal.topic.getRawQuery()
+        query = self.portal.topic.query
         self.assertEqual(
             query,
             [{'i': 'portal_type',
@@ -511,8 +521,9 @@ def test_ATPortalTypeCriterionOfTopic(self):
             'portal_type',
             'ATPortalTypeCriterion', ('Topic',)
         )
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        query = self.portal.topic.getRawQuery()
+        query = self.portal.topic.query
         self.assertEqual(query,
                          [{'i': 'portal_type',
                            'o': 'plone.app.querystring.operation.selection.is',
@@ -540,8 +551,9 @@ def test_ATSelectionCriterion(self):
         )
         crit.setOperator('and')
 
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        query = self.portal.topic.getRawQuery()
+        query = self.portal.topic.query
         self.assertEqual(len(query), 2)
         self.assertEqual(query[0],
                          {'i': 'Subject',
@@ -564,8 +576,9 @@ def test_ATSelectionCriterionForTypeTitle(self):
         # Type is not enabled as criterion index by default, so we
         # want to migrate to a portal_type criterion instead.
         self.add_criterion('Type', 'ATSelectionCriterion', ('Page', 'Folder'))
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        query = self.portal.topic.getRawQuery()
+        query = self.portal.topic.query
         self.assertEqual(query,
                          [{'i': 'portal_type',
                            'o': 'plone.app.querystring.operation.selection.is',
@@ -583,10 +596,11 @@ def test_ATReferenceCriterion(self):
             'ATReferenceCriterion',
             self.portal.folder.UID()
         )
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
         # TODO re-enable this check when the queryparser works.
         # self.assertEqual(
-        #     self.portal.topic.getRawQuery(),
+        #     self.portal.topic.query,
         #     [{'i': 'getRawRelatedItems',
         #       'o': 'plone.app.querystring.operation.reference.is',
         #       'v': (portal.folder.UID(),)}]
@@ -601,9 +615,10 @@ def test_ATRelativePathCriterion(self):
             'ATRelativePathCriterion'
         )
         crit.setRelativePath('../folder')
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
         self.assertEqual(
-            self.portal.topic.getRawQuery(),
+            self.portal.topic.query,
             [{'i': 'path',
               'o': 'plone.app.querystring.operation.string.relativePath',
               'v': '../folder'}]
@@ -618,9 +633,10 @@ def test_ATRelativePathCriterionNonRecursive(self):
         crit = self.add_criterion('path', 'ATRelativePathCriterion')
         crit.setRelativePath('../folder')
         crit.setRecurse(True)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
         self.assertEqual(
-            self.portal.topic.getRawQuery(),
+            self.portal.topic.query,
             [{'i': 'path',
               'o': 'plone.app.querystring.operation.string.relativePath',
               'v': '../folder'}])
@@ -630,8 +646,9 @@ def test_ATRelativePathCriterionNonRecursive(self):
 
     def test_ATSimpleIntCriterion(self):
         self.add_criterion('getObjPositionInParent', 'ATSimpleIntCriterion', 7)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        self.assertEqual(self.portal.topic.getRawQuery(),
+        self.assertEqual(self.portal.topic.query,
                          [{'i': 'getObjPositionInParent',
                            'o': 'plone.app.querystring.operation.int.is',
                            'v': 7}])
@@ -645,9 +662,10 @@ def test_ATSimpleIntCriterionMinimum(self):
             'ATSimpleIntCriterion', 6
         )
         crit.setDirection('min')
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
         self.assertEqual(
-            self.portal.topic.getRawQuery(),
+            self.portal.topic.query,
             [{'i': 'getObjPositionInParent',
               'o': 'plone.app.querystring.operation.int.largerThan',
               'v': 6}]
@@ -663,9 +681,10 @@ def test_ATSimpleIntCriterionMaximum(self):
             5
         )
         crit.setDirection('max')
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
         self.assertEqual(
-            self.portal.topic.getRawQuery(),
+            self.portal.topic.query,
             [{'i': 'getObjPositionInParent',
               'o': 'plone.app.querystring.operation.int.lessThan',
               'v': 5}]
@@ -683,18 +702,20 @@ def test_ATSimpleIntCriterionBetween(self):
         )
         crit.setDirection('min:max')
         crit.setValue2(8)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        self.assertEqual(self.portal.topic.getRawQuery(), [])
+        self.assertEqual(self.portal.topic.query, [])
 
         # Check that the resulting query does not give an error.
         self.portal.topic.getQuery()
 
     def test_ATSortCriterion(self):
         self.add_criterion('modified', 'ATSortCriterion')
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        self.assertEqual(self.portal.topic.getSort_on(), 'modified')
-        self.assertEqual(self.portal.topic.getSort_reversed(), False)
-        self.assertEqual(self.portal.topic.getRawQuery(), [])
+        self.assertEqual(self.portal.topic.sort_on, 'modified')
+        self.assertEqual(self.portal.topic.sort_reversed, False)
+        self.assertEqual(self.portal.topic.query, [])
 
         # Check that the resulting query does not give an error.
         self.portal.topic.getQuery()
@@ -702,10 +723,11 @@ def test_ATSortCriterion(self):
     def test_ATSortCriterionReversed(self):
         crit = self.add_criterion('created', 'ATSortCriterion')
         crit.setReversed(True)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
         self.run_migration()
-        self.assertEqual(self.portal.topic.getSort_on(), 'created')
-        self.assertEqual(self.portal.topic.getSort_reversed(), True)
-        self.assertEqual(self.portal.topic.getRawQuery(), [])
+        self.assertEqual(self.portal.topic.sort_on, 'created')
+        self.assertEqual(self.portal.topic.sort_reversed, True)
+        self.assertEqual(self.portal.topic.query, [])
 
         # Check that the resulting query does not give an error.
         self.portal.topic.getQuery()


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-12-28T16:33:44+01:00
Author: Philip Bauer (pbauer) <bauer@starzel.de>
Commit: https://github.com/plone/plone.app.contenttypes/commit/9e7667edcfbdaba9be41f5bae4213182509cf2ed

use folderish migrator if collections are folderish

Files changed:
M plone/app/contenttypes/content.py
M plone/app/contenttypes/migration/topics.py
M plone/app/contenttypes/tests/test_migration_topic.py

diff --git a/plone/app/contenttypes/content.py b/plone/app/contenttypes/content.py
index 5b8d4e5..93b8799 100644
--- a/plone/app/contenttypes/content.py
+++ b/plone/app/contenttypes/content.py
@@ -13,7 +13,7 @@
 
 
 @implementer(ICollection)
-class Collection(Item):
+class Collection(Container):
     # BBB
 
     def listMetaDataFields(self, exclude=True):
diff --git a/plone/app/contenttypes/migration/topics.py b/plone/app/contenttypes/migration/topics.py
index 8428fd2..fa0aa36 100644
--- a/plone/app/contenttypes/migration/topics.py
+++ b/plone/app/contenttypes/migration/topics.py
@@ -1,31 +1,11 @@
 # -*- coding: utf-8 -*-
+""" Migrate Topic to DX-Collectons.
 
-"""
-TODO/plans for topic migration.
-
-- Fix the TODO items embedded in the code below, or document missing
-  features.  This may need additions in plone.app.querystring, or
-  needs checking to see if features are already supported in there
-  meanwhile.
-
-- When needed, register a FolderishCollection type: Folder with
-  Collection behavior enabled.  We can use this to migrate Topics with
-  sub topics.
-
-- See if we can get migrations to work for anything we can come up with:
-
-  - Plain AT Topics.
-
-  - AT Topics with sub Topics inside.
-
-  - plone.app.collection 1.x Archetypes
-
-  - plone.app.collection 1.x Archetypes folderish (in use by Maurits,
-    see p.a.collection branch maurits-upgrade-path, where this
-    migration code actually originated)
-
-  - plone.app.collection 2.x Dexterity
-
+Note on Subtopics:
+When a migration of Subtopics is needed, you can replace the default itemish
+Collection with a folderish Collection by creating a new type folderish
+type 'Collection' with the collection-behavior enabled. You can then use
+the default migration to migrate Topics with Subtopics.
 """
 
 from DateTime import DateTime
@@ -49,32 +29,6 @@
 prefix = "plone.app.querystring"
 
 INVALID_OPERATION = 'Invalid operation %s for criterion: %s'
-# PROFILE_ID = 'profile-plone.app.collection:default'
-
-
-def format_date(value):
-    """Format the date.
-
-    The value is expected to be a DateTime.DateTime object, though it
-    actually also works on datetime.datetime objects.
-
-    The query field expects a string with month/date/year.
-    So 28 March 2013 should become '03/28/2013'.
-    """
-    return value.strftime('%m/%d/%Y')
-
-
-def is_old_non_folderish_item(obj, **kwargs):
-    """Is this an old not yet migrated Collection item?
-
-    The old non-folderish and new folderish Collections have the same
-    meta_type and portal_type, which means a simple catalog walker
-    will crash when it is called on a new folderish Collection, for
-    example when the upgrade step is run twice.  We can use this
-    function to ignore the new Collections.
-    """
-    return not IFolderish(obj)
-
 
 # Converters
 
@@ -483,6 +437,11 @@ def get_query_value(self, value, index, criterion):
 
 
 class TopicMigrator(InplaceCMFItemMigrator, ReferenceMigrator):
+    """Migrate Topics to Collections. Existing subtopics will be lost.
+
+    The only difference to the migration below is the base-class
+    (InplaceCMFItemMigrator instead of InplaceCMFFolderMigrator).
+    """
     src_portal_type = 'Topic'
     src_meta_type = 'ATTopic'
     dst_portal_type = dst_meta_type = 'Collection'
@@ -590,51 +549,117 @@ def migrate_at_uuid(self):
             IMutableUUID(self.new).set(str(uid))
 
 
-class FolderishCollectionMigrator(InplaceCMFFolderMigrator, ReferenceMigrator):
-    """stub Migration for ATTopic -> Folderish Collections
+class FolderishTopicMigrator(InplaceCMFFolderMigrator, ReferenceMigrator):
+    """Migrate Topics and Subtopics to folderish collections.
+
+    The only difference to the migration above is the base-class
+    (InplaceCMFFolderMigrator instead of InplaceCMFItemMigrator).
     """
-    src_portal_type = src_meta_type = 'Collection'
+    src_portal_type = 'Topic'
+    src_meta_type = 'ATTopic'
     dst_portal_type = dst_meta_type = 'Collection'
+    view_methods_mapping = {
+        'folder_listing': 'standard_view',
+        'folder_summary_view': 'summary_view',
+        'folder_full_view': 'all_content',
+        'folder_tabular_view': 'tabular_view',
+        'atct_album_view': 'thumbnail_view',
+        'atct_topic_view': 'standard_view',
+        }
 
+    @property
+    def registry(self):
+        return self.kwargs['registry']
 
-def migrate_to_folderish_collections(context):
-    """Migrate new-style Collections to folderish Collections.
+    def last_migrate_layout(self):
+        """Migrate the layout (view method).
 
-    This can be used as upgrade step.
+        This needs to be done last, as otherwise our changes in
+        migrate_criteria may get overriden by a later call to
+        migrate_properties.
+        """
+        if self.old.getCustomView():
+            # Previously, the atct_topic_view had logic for showing
+            # the results in a list or in tabular form.  If
+            # getCustomView is True, this means the new object should
+            # use the tabular view.
+            self.new.setLayout('tabular_view')
+            return
 
-    The new-style Collections started out as inheriting from
-    ATDocument.  Historically users could nest topics, so we want to
-    try to bring that back.  This is the first step: make existing
-    new-style Collections folderish.
+        old_layout = self.old.getLayout() or getattr(self.old, 'layout', None)
+        layout = self.view_methods_mapping.get(old_layout)
+        if layout:
+            self.new.setLayout(layout)
 
-    TODO/notes:
+    def beforeChange_criteria(self):
+        """Store the criteria of the old Topic.
 
-    - This simple migration seems to work.
+        Store the info on the migrator and restore the values in the
+        migrate_criteria method.
+        """
+        self._collection_sort_reversed = None
+        self._collection_sort_on = None
+        self._collection_query = None
+        path = '/'.join(self.old.getPhysicalPath())
+        logger.info("Migrating %s at %s", self.src_portal_type, path)
+        # Get the old criteria.
+        # See also Products.ATContentTypes.content.topic.buildQuery
+        criteria = self.old.listCriteria()
+        logger.debug("Old criteria for %s: %r", path,
+                     [(crit, crit.getCriteriaItems()) for crit in criteria])
+        formquery = []
+        for criterion in criteria:
+            type_ = criterion.__class__.__name__
+            if type_ == 'ATSortCriterion':
+                # Sort order and direction are now stored in the Collection.
+                self._collection_sort_reversed = criterion.getReversed()
+                self._collection_sort_on = criterion.Field()
+                logger.debug("Sort on %r, reverse: %s.",
+                             self._collection_sort_on,
+                             self._collection_sort_reversed)
+                continue
 
-    - The sub collection should 'inherit' the query from its parent,
-      otherwise this exercise does not make much sense.  See the
-      maurits-recursive branch of archetypes.querywidget, which seems
-      to work, though for the tests to pass it currently needs the
-      maurits-upgradepath branch of plone.app.collection.
+            converter = CONVERTERS.get(type_)
+            if converter is None:
+                msg = 'Unsupported criterion %s' % type_
+                logger.error(msg)
+                raise ValueError(msg)
+            converter(formquery, criterion, self.registry)
 
-    """
-    site = getToolByName(context, 'portal_url').getPortalObject()
-    collection_walker = CustomQueryWalker(
-        site, FolderishCollectionMigrator,
-        callBefore=is_old_non_folderish_item)
-    collection_walker.go()
+        logger.debug("New query for %s: %r", path, formquery)
+        self._collection_query = formquery
 
+    def migrate_criteria(self):
+        """Migrate old style to new style criteria.
 
-def run_typeinfo_step(context):
-    context.runImportStepFromProfile(PROFILE_ID, 'typeinfo')
+        Plus handling for some special fields.
+        """
+        # The old Topic has boolean limitNumber and integer itemCount,
+        # where the new Collection only has limit.
+        adapted = ICollection(self.new)
+        if self.old.getLimitNumber():
+            adapted.limit = self.old.getItemCount()
+        adapted.customViewFields = self.old.getCustomViewFields()
 
+        # Get the old data stored by the beforeChange_criteria method.
+        if self._collection_sort_reversed is not None:
+            adapted.sort_reversed = self._collection_sort_reversed
+        if self._collection_sort_on is not None:
+            adapted.sort_on = self._collection_sort_on
+        if self._collection_query is not None:
+            adapted.query = self._collection_query
 
-def run_actions_step(context):
-    context.runImportStepFromProfile(PROFILE_ID, 'actions')
 
+    def migrate_atctmetadata(self):
+        field = self.old.getField('excludeFromNav')
+        self.new.exclude_from_nav = field.get(self.old)
 
-def run_propertiestool_step(context):
-    context.runImportStepFromProfile(PROFILE_ID, 'propertiestool')
+    def migrate_at_uuid(self):
+        """Migrate AT universal uid
+        """
+        uid = self.UID
+        if uid and queryAdapter(self.new, IMutableUUID):
+            IMutableUUID(self.new).set(str(uid))
 
 
 def migrate_topics(portal):
@@ -647,7 +672,13 @@ def migrate_topics(portal):
     reg = getUtility(IRegistry)
     reader = IQuerystringRegistryReader(reg)
     registry = reader.parseRegistry()
-    walker = CustomQueryWalker(portal, TopicMigrator)(registry=registry)
+    # select migrator based on the base-class of collections
+    fti = portal.portal_types['Collection']
+    if fti.content_meta_type == "Dexterity Item":
+        migrator = TopicMigrator
+    elif fti.content_meta_type == "Dexterity Container":
+        migrator = FolderishTopicMigrator
+    walker = CustomQueryWalker(portal, migrator)(registry=registry)
     return walker
 
 
diff --git a/plone/app/contenttypes/tests/test_migration_topic.py b/plone/app/contenttypes/tests/test_migration_topic.py
index 047f6b9..07094f9 100644
--- a/plone/app/contenttypes/tests/test_migration_topic.py
+++ b/plone/app/contenttypes/tests/test_migration_topic.py
@@ -5,12 +5,22 @@
 from plone.app.contenttypes.behaviors.collection import ICollection
 from plone.app.contenttypes.testing import \
     PLONE_APP_CONTENTTYPES_MIGRATION_TESTING
+from plone.dexterity.content import Container
 from plone.app.testing import applyProfile
 from plone.app.testing import login
+from plone.dexterity.interfaces import IDexterityFTI
+from zope.component import queryUtility
+from zope.interface import implementer
 
 import unittest
 
 
+@implementer(ICollection)
+class FolderishCollection(Container):
+   """Test subclass for folderish ``Collections``.
+   """
+
+
 class MigrateTopicsIntegrationTest(unittest.TestCase):
 
     layer = PLONE_APP_CONTENTTYPES_MIGRATION_TESTING
@@ -82,12 +92,15 @@ def test_migrate_customView(self):
         self.run_migration()
         self.assertEqual(self.portal.topic.getLayout(), 'tabular_view')
 
-    @unittest.skip("Only works when migrating to folderish collections")
     def test_migrate_nested_topic(self):
-        self.portal.portal_types.Topic.filter_content_types = False
-        self.portal.portal_types.Collection.filter_content_types = False
+        self.portal.portal_types['Topic'].filter_content_types = False
         self.portal.topic.invokeFactory("Topic", "subtopic", title="Sub Topic")
         applyProfile(self.portal, 'plone.app.contenttypes:default')
+        fti = queryUtility(IDexterityFTI, name='Collection')
+        fti.allowed_content_types = ['Document', 'Folder']
+        # switch our a custom folderish base-class for collections
+        fti.klass = 'plone.app.contenttypes.tests.test_migration_topic.FolderishCollection'
+        fti.filter_content_types = False
         self.run_migration()
         self.assertEqual(self.portal.topic.portal_type, 'Collection')
         self.assertEqual(self.portal.topic.subtopic.portal_type, 'Collection')


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-12-28T20:27:47+01:00
Author: Philip Bauer (pbauer) <bauer@starzel.de>
Commit: https://github.com/plone/plone.app.contenttypes/commit/f2b5c55b3ca951ba1a059caf2524843062e2a4a6

revert accidental change to folderish collections

Files changed:
M plone/app/contenttypes/content.py

diff --git a/plone/app/contenttypes/content.py b/plone/app/contenttypes/content.py
index 93b8799..5b8d4e5 100644
--- a/plone/app/contenttypes/content.py
+++ b/plone/app/contenttypes/content.py
@@ -13,7 +13,7 @@
 
 
 @implementer(ICollection)
-class Collection(Container):
+class Collection(Item):
     # BBB
 
     def listMetaDataFields(self, exclude=True):


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-12-28T20:55:38+01:00
Author: Philip Bauer (pbauer) <bauer@starzel.de>
Commit: https://github.com/plone/plone.app.contenttypes/commit/3af8bb675ec9ecbc9cd49043644eaedb609529bc

allow migration even if old FTI is missing

Files changed:
M plone/app/contenttypes/migration/utils.py

diff --git a/plone/app/contenttypes/migration/utils.py b/plone/app/contenttypes/migration/utils.py
index d007bd0..8959d2f 100644
--- a/plone/app/contenttypes/migration/utils.py
+++ b/plone/app/contenttypes/migration/utils.py
@@ -100,7 +100,8 @@ def installTypeIfNeeded(type_name):
     if IDexterityFTI.providedBy(fti):
         # the dx-type is already installed
         return
-    tt.manage_delObjects(type_name)
+    if fti:
+        tt.manage_delObjects(type_name)
     tt.manage_addTypeInformation('Dexterity FTI', id=type_name)
     dx_fti = tt.getTypeInfo(type_name)
     ps = getToolByName(portal, 'portal_setup')


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-12-28T21:08:43+01:00
Author: Philip Bauer (pbauer) <bauer@starzel.de>
Commit: https://github.com/plone/plone.app.contenttypes/commit/26332ff46a868e17c2dca8c680e6e9f34bd2b4de

Check for subtopics and folderish Collections before migration.

Files changed:
M plone/app/contenttypes/migration/atct_migrator.pt
M plone/app/contenttypes/migration/browser.py

diff --git a/plone/app/contenttypes/migration/atct_migrator.pt b/plone/app/contenttypes/migration/atct_migrator.pt
index fabe764..02528bf 100644
--- a/plone/app/contenttypes/migration/atct_migrator.pt
+++ b/plone/app/contenttypes/migration/atct_migrator.pt
@@ -26,8 +26,11 @@
 
 <metal:content-core fill-slot="content-core">
 <metal:block define-macro="content-core"
-    tal:define="linguaplone_installed context/@@atct_migrator_helpers/linguaplone_installed;
-                object_number context/@@atct_migrator_helpers/objects_to_be_migrated">
+             tal:define="helpers context/@@atct_migrator_helpers;
+                         linguaplone_installed helpers/linguaplone_installed;
+                         object_number helpers/objects_to_be_migrated;
+                         subtopics helpers/site_has_subtopics;
+                         collections_are_folderish helpers/collections_are_folderish;">
   <div class="important">
     <h2 i18n:translate="">Important</h2>
     <p i18n:translate="">Before migrating your content please read the <a href="https://github.com/plone/plone.app.contenttypes#migration">migration-section in the documentation of plone.app.contenttypes</a></p>
@@ -38,14 +41,14 @@
     If you need to keep the data in these fields you need to write a custom migration for your types and dexterity-behaviors for the data stored in the extended fields.</p>
     <h3 i18n:translate="">Note about how long it might take</h3>
     <p i18n:translate="">Depending of the number of archetypes in your portal, it may take a <span class="strong">really</span> long time for the migration to be done.
-      Stay calm and be patient.</p>
+    Stay calm, be patient and check your logs for progress-information.</p>
   </div>
   <tal:block condition="object_number">
     <tal:block condition="not:linguaplone_installed">
       <div class="important">
         <h2 i18n:translate="">Estimation</h2>
-        <p i18n:translate="">You currently have <span class="strong" i18n:name="number_of_objects" tal:content="context/@@atct_migrator_helpers/objects_to_be_migrated">23</span> archetypes objects to be migrated.</p>
-        <p i18n:translate="" tal:define="time context/@@atct_migrator_helpers/estimated_migration_time">The estimated time for the migration is around :
+        <p i18n:translate="">You currently have <span class="strong" i18n:name="number_of_objects" tal:content="helpers/objects_to_be_migrated">23</span> archetypes objects to be migrated.</p>
+        <p i18n:translate="" tal:define="time helpers/estimated_migration_time">The estimated time for the migration is around :
         <span class="strong">
           <tal:block i18n:name="hours" content="time/hours">4</tal:block> hours
           <tal:block i18n:name="minutes" content="time/minutes">45</tal:block> minutes
@@ -74,7 +77,24 @@
         <p i18n:translate="" class="strong">Since you effectively have Products.LinguaPlone installed in your portal, you're not allowed to continue this migration.</p>
         <p i18n:translate="">Have a look above for more information.</p>
       </div>
-      </tal:block>
+    </tal:block>
+    <tal:block condition="python: subtopics and not collections_are_folderish">
+      <div class="important">
+        <h2 i18n:translate="">Error</h2>
+        <p i18n:translate="">Your site has <span tal:replace="python: len(subtopics)" /> subtopic(s). Before migrating them you have to either remove all subtopics or create folderish Collections to be able to migrate them. Inheriting criteria to sub-collections is not supported by the default types. You can still migrate other content.</p>
+        <p><a i18n:translate="" href="https://pypi.python.org/pypi/plone.app.contenttypes#migration">See here for details.</a></p>
+        <p i18n:translate="">Your subtopics:</p>
+        <ul>
+          <li tal:repeat="subtopic subtopics"><a href="" target="_new" tal:attributes="href subtopic" tal:content="subtopic"></li>
+        </ul>
+      </div>
+    </tal:block>
+    <tal:block condition="python: subtopics and collections_are_folderish">
+      <div class="important">
+        <h2 i18n:translate="">Warning</h2>
+        <p i18n:translate="">Your site has <span tal:replace="python: len(subtopics)" /> subtopic(s). You can migrate them since you have folderish Collections but their functionality to inherit criteria will not work unless you.</p>
+      </div>
+    </tal:block>
   </tal:block>
   <tal:block condition="not:object_number">
     <div class="important">
diff --git a/plone/app/contenttypes/migration/browser.py b/plone/app/contenttypes/migration/browser.py
index 841b6d3..85ddf92 100644
--- a/plone/app/contenttypes/migration/browser.py
+++ b/plone/app/contenttypes/migration/browser.py
@@ -18,6 +18,7 @@
 from plone.browserlayer.interfaces import ILocalBrowserLayerType
 from plone.dexterity.content import DexterityContent
 from plone.dexterity.interfaces import IDexterityContent
+from plone.dexterity.interfaces import IDexterityFTI
 from plone.z3cform.layout import wrap_form
 from pprint import pformat
 from z3c.form import button
@@ -391,6 +392,50 @@ def linguaplone_installed(self):
         existing = queryUtility(ILocalBrowserLayerType, name='LinguaPlone')
         return bool(existing)
 
+    def site_has_subtopics(self):
+        """Check if there are subtopics. Since Collections are itemish by
+        default the migration of subtopics would fail Collections are changed
+        to be folderish.
+        """
+        catalog = getToolByName(self.context, "portal_catalog")
+        query = {'meta_type': 'ATTopic'}
+        results = []
+        if HAS_MULTILINGUAL and 'Language' in catalog.indexes():
+            query['Language'] = 'all'
+        brains = catalog(query)
+        for brain in brains:
+            for item in catalog(path={'query': brain.getPath(), 'depth': 1}):
+                results.append(item.getURL())
+        if results:
+            results = set(results)
+            paths = "\n".join(results)
+            logger.info("Found {0} subtopics at: \n{1}".format(
+                len(results), paths))
+            return results
+
+    def collections_are_folderish(self):
+        """Since Collections are itemish by default the migration would fail
+        if there are any subtopics. As a workaround we allow to migrate to
+        custom folderish Collections. The custom Collections have to fulfill
+        the following criteria:
+        1. The id if the type has to be Collection (not collection). You can
+           change a type's id in portal_types
+        2. The type has to have the collection-behavior.
+
+        This much can even be done ttw. For the views of collections
+        to work the base-class of the Collections also has to implement the
+        interface `plone.app.contenttypes.interfaces.ICollection`.
+        """
+        fti = queryUtility(IDexterityFTI, name="Collection")
+        if fti and fti.content_meta_type == "Dexterity Container":
+            return True
+        # test for lowercase ttw-type
+        fti = queryUtility(IDexterityFTI, name="collection")
+        behavior = 'plone.app.contenttypes.behaviors.collection.ICollection'
+        if fti and behavior in fti.behaviors:
+            logger.warn("You are trying to migrate topic to collection. "
+                "Instead you need a type 'Collection'.")
+
 
 class ATCTMigratorResults(BrowserView):
 


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-12-29T09:31:21+01:00
Author: Philip Bauer (pbauer) <bauer@starzel.de>
Commit: https://github.com/plone/plone.app.contenttypes/commit/2d7ea139f80deddd22aa53c68b075ff913cd14a8

fix test and add more info on subtopic-migration

Files changed:
M plone/app/contenttypes/migration/browser.py
M plone/app/contenttypes/tests/test_migration_topic.py

diff --git a/plone/app/contenttypes/migration/browser.py b/plone/app/contenttypes/migration/browser.py
index 85ddf92..7a3cf7c 100644
--- a/plone/app/contenttypes/migration/browser.py
+++ b/plone/app/contenttypes/migration/browser.py
@@ -425,6 +425,25 @@ def collections_are_folderish(self):
         This much can even be done ttw. For the views of collections
         to work the base-class of the Collections also has to implement the
         interface `plone.app.contenttypes.interfaces.ICollection`.
+
+        This is what such a class would look like:
+
+            from plone.app.contenttypes.behaviors.collection import ICollection
+            from plone.dexterity.content import Container
+            from zope.interface import implementer
+
+            @implementer(ICollection)
+            class FolderishCollection(Container):
+                pass
+
+        You can either use a completely new fti or overwrite the default fti
+        like this:
+
+            <?xml version="1.0"?>
+            <object name="Collection" meta_type="Dexterity FTI">
+             <property name="klass">my.package.content.FolderishCollection</property>
+            </object>
+
         """
         fti = queryUtility(IDexterityFTI, name="Collection")
         if fti and fti.content_meta_type == "Dexterity Container":
diff --git a/plone/app/contenttypes/tests/test_migration_topic.py b/plone/app/contenttypes/tests/test_migration_topic.py
index 07094f9..0a07c00 100644
--- a/plone/app/contenttypes/tests/test_migration_topic.py
+++ b/plone/app/contenttypes/tests/test_migration_topic.py
@@ -97,10 +97,14 @@ def test_migrate_nested_topic(self):
         self.portal.topic.invokeFactory("Topic", "subtopic", title="Sub Topic")
         applyProfile(self.portal, 'plone.app.contenttypes:default')
         fti = queryUtility(IDexterityFTI, name='Collection')
-        fti.allowed_content_types = ['Document', 'Folder']
         # switch our a custom folderish base-class for collections
-        fti.klass = 'plone.app.contenttypes.tests.test_migration_topic.FolderishCollection'
-        fti.filter_content_types = False
+        # we need to use _updateProperty because this also refreshes
+        # the content_meta_type attribute when klass has changed
+        fti._updateProperty(
+            'klass',
+            'plone.app.contenttypes.tests.test_migration_topic.FolderishCollection')
+        fti._updateProperty('allowed_content_types', ['Document', 'Folder'])
+        fti._updateProperty('filter_content_types', False)
         self.run_migration()
         self.assertEqual(self.portal.topic.portal_type, 'Collection')
         self.assertEqual(self.portal.topic.subtopic.portal_type, 'Collection')


Repository: plone.app.contenttypes
Branch: refs/heads/master
Date: 2014-12-29T09:58:36+01:00
Author: Philip Bauer (pbauer) <bauer@starzel.de>
Commit: https://github.com/plone/plone.app.contenttypes/commit/c4cb5fd8d8b6b2039b9d003bbb6f9c9c5e0aa2c8

Merge pull request #198 from plone/migrate-topics-to-dx

Migrate topics to collections

Files changed:
A plone/app/contenttypes/migration/topics.py
A plone/app/contenttypes/tests/test_migration_topic.py
M buildout.cfg
M plone/app/contenttypes/migration/atct_migrator.pt
M plone/app/contenttypes/migration/browser.py
M plone/app/contenttypes/migration/utils.py
M plone/app/contenttypes/migration/vocabularies.py
M plone/app/contenttypes/testing.py
M plone/app/contenttypes/tests/test_migration.py

diff --git a/buildout.cfg b/buildout.cfg
index 1e41162..ff66b9f 100644
--- a/buildout.cfg
+++ b/buildout.cfg
@@ -1,3 +1,2 @@
 [buildout]
 extends = test-plone-4.3.x.cfg
-
diff --git a/plone/app/contenttypes/migration/atct_migrator.pt b/plone/app/contenttypes/migration/atct_migrator.pt
index fabe764..02528bf 100644
--- a/plone/app/contenttypes/migration/atct_migrator.pt
+++ b/plone/app/contenttypes/migration/atct_migrator.pt
@@ -26,8 +26,11 @@
 
 <metal:content-core fill-slot="content-core">
 <metal:block define-macro="content-core"
-    tal:define="linguaplone_installed context/@@atct_migrator_helpers/linguaplone_installed;
-                object_number context/@@atct_migrator_helpers/objects_to_be_migrated">
+             tal:define="helpers context/@@atct_migrator_helpers;
+                         linguaplone_installed helpers/linguaplone_installed;
+                         object_number helpers/objects_to_be_migrated;
+                         subtopics helpers/site_has_subtopics;
+                         collections_are_folderish helpers/collections_are_folderish;">
   <div class="important">
     <h2 i18n:translate="">Important</h2>
     <p i18n:translate="">Before migrating your content please read the <a href="https://github.com/plone/plone.app.contenttypes#migration">migration-section in the documentation of plone.app.contenttypes</a></p>
@@ -38,14 +41,14 @@
     If you need to keep the data in these fields you need to write a custom migration for your types and dexterity-behaviors for the data stored in the extended fields.</p>
     <h3 i18n:translate="">Note about how long it might take</h3>
     <p i18n:translate="">Depending of the number of archetypes in your portal, it may take a <span class="strong">really</span> long time for the migration to be done.
-      Stay calm and be patient.</p>
+    Stay calm, be patient and check your logs for progress-information.</p>
   </div>
   <tal:block condition="object_number">
     <tal:block condition="not:linguaplone_installed">
       <div class="important">
         <h2 i18n:translate="">Estimation</h2>
-        <p i18n:translate="">You currently have <span class="strong" i18n:name="number_of_objects" tal:content="context/@@atct_migrator_helpers/objects_to_be_migrated">23</span> archetypes objects to be migrated.</p>
-        <p i18n:translate="" tal:define="time context/@@atct_migrator_helpers/estimated_migration_time">The estimated time for the migration is around :
+        <p i18n:translate="">You currently have <span class="strong" i18n:name="number_of_objects" tal:content="helpers/objects_to_be_migrated">23</span> archetypes objects to be migrated.</p>
+        <p i18n:translate="" tal:define="time helpers/estimated_migration_time">The estimated time for the migration is around :
         <span class="strong">
           <tal:block i18n:name="hours" content="time/hours">4</tal:block> hours
           <tal:block i18n:name="minutes" content="time/minutes">45</tal:block> minutes
@@ -74,7 +77,24 @@
         <p i18n:translate="" class="strong">Since you effectively have Products.LinguaPlone installed in your portal, you're not allowed to continue this migration.</p>
         <p i18n:translate="">Have a look above for more information.</p>
       </div>
-      </tal:block>
+    </tal:block>
+    <tal:block condition="python: subtopics and not collections_are_folderish">
+      <div class="important">
+        <h2 i18n:translate="">Error</h2>
+        <p i18n:translate="">Your site has <span tal:replace="python: len(subtopics)" /> subtopic(s). Before migrating them you have to either remove all subtopics or create folderish Collections to be able to migrate them. Inheriting criteria to sub-collections is not supported by the default types. You can still migrate other content.</p>
+        <p><a i18n:translate="" href="https://pypi.python.org/pypi/plone.app.contenttypes#migration">See here for details.</a></p>
+        <p i18n:translate="">Your subtopics:</p>
+        <ul>
+          <li tal:repeat="subtopic subtopics"><a href="" target="_new" tal:attributes="href subtopic" tal:content="subtopic"></li>
+        </ul>
+      </div>
+    </tal:block>
+    <tal:block condition="python: subtopics and collections_are_folderish">
+      <div class="important">
+        <h2 i18n:translate="">Warning</h2>
+        <p i18n:translate="">Your site has <span tal:replace="python: len(subtopics)" /> subtopic(s). You can migrate them since you have folderish Collections but their functionality to inherit criteria will not work unless you.</p>
+      </div>
+    </tal:block>
   </tal:block>
   <tal:block condition="not:object_number">
     <div class="important">
diff --git a/plone/app/contenttypes/migration/browser.py b/plone/app/contenttypes/migration/browser.py
index f7e2bb4..91ce767 100644
--- a/plone/app/contenttypes/migration/browser.py
+++ b/plone/app/contenttypes/migration/browser.py
@@ -19,6 +19,7 @@
 from plone.browserlayer.interfaces import ILocalBrowserLayerType
 from plone.dexterity.content import DexterityContent
 from plone.dexterity.interfaces import IDexterityContent
+from plone.dexterity.interfaces import IDexterityFTI
 from plone.z3cform.layout import wrap_form
 from pprint import pformat
 from z3c.form import button
@@ -462,6 +463,69 @@ def linguaplone_installed(self):
         existing = queryUtility(ILocalBrowserLayerType, name='LinguaPlone')
         return bool(existing)
 
+    def site_has_subtopics(self):
+        """Check if there are subtopics. Since Collections are itemish by
+        default the migration of subtopics would fail Collections are changed
+        to be folderish.
+        """
+        catalog = getToolByName(self.context, "portal_catalog")
+        query = {'meta_type': 'ATTopic'}
+        results = []
+        if HAS_MULTILINGUAL and 'Language' in catalog.indexes():
+            query['Language'] = 'all'
+        brains = catalog(query)
+        for brain in brains:
+            for item in catalog(path={'query': brain.getPath(), 'depth': 1}):
+                results.append(item.getURL())
+        if results:
+            results = set(results)
+            paths = "\n".join(results)
+            logger.info("Found {0} subtopics at: \n{1}".format(
+                len(results), paths))
+            return results
+
+    def collections_are_folderish(self):
+        """Since Collections are itemish by default the migration would fail
+        if there are any subtopics. As a workaround we allow to migrate to
+        custom folderish Collections. The custom Collections have to fulfill
+        the following criteria:
+        1. The id if the type has to be Collection (not collection). You can
+           change a type's id in portal_types
+        2. The type has to have the collection-behavior.
+
+        This much can even be done ttw. For the views of collections
+        to work the base-class of the Collections also has to implement the
+        interface `plone.app.contenttypes.interfaces.ICollection`.
+
+        This is what such a class would look like:
+
+            from plone.app.contenttypes.behaviors.collection import ICollection
+            from plone.dexterity.content import Container
+            from zope.interface import implementer
+
+            @implementer(ICollection)
+            class FolderishCollection(Container):
+                pass
+
+        You can either use a completely new fti or overwrite the default fti
+        like this:
+
+            <?xml version="1.0"?>
+            <object name="Collection" meta_type="Dexterity FTI">
+             <property name="klass">my.package.content.FolderishCollection</property>
+            </object>
+
+        """
+        fti = queryUtility(IDexterityFTI, name="Collection")
+        if fti and fti.content_meta_type == "Dexterity Container":
+            return True
+        # test for lowercase ttw-type
+        fti = queryUtility(IDexterityFTI, name="collection")
+        behavior = 'plone.app.contenttypes.behaviors.collection.ICollection'
+        if fti and behavior in fti.behaviors:
+            logger.warn("You are trying to migrate topic to collection. "
+                "Instead you need a type 'Collection'.")
+
 
 class ATCTMigratorResults(BrowserView):
 
diff --git a/plone/app/contenttypes/migration/topics.py b/plone/app/contenttypes/migration/topics.py
new file mode 100644
index 0000000..fa0aa36
--- /dev/null
+++ b/plone/app/contenttypes/migration/topics.py
@@ -0,0 +1,699 @@
+# -*- coding: utf-8 -*-
+""" Migrate Topic to DX-Collectons.
+
+Note on Subtopics:
+When a migration of Subtopics is needed, you can replace the default itemish
+Collection with a folderish Collection by creating a new type folderish
+type 'Collection' with the collection-behavior enabled. You can then use
+the default migration to migrate Topics with Subtopics.
+"""
+
+from DateTime import DateTime
+from Products.CMFCore.interfaces._content import IFolderish
+from Products.CMFCore.utils import getToolByName
+from Products.contentmigration.inplace import InplaceCMFFolderMigrator
+from Products.contentmigration.inplace import InplaceCMFItemMigrator
+from Products.contentmigration.walker import CustomQueryWalker
+from plone.app.contenttypes.behaviors.collection import ICollection
+from plone.app.contenttypes.migration.migration import ReferenceMigrator
+from plone.app.querystring.interfaces import IQuerystringRegistryReader
+from plone.registry.interfaces import IRegistry
+from plone.uuid.interfaces import IMutableUUID
+from zope.component import getUtility
+from zope.component import queryAdapter
+from zope.dottedname.resolve import resolve
+
+import logging
+
+logger = logging.getLogger(__name__)
+prefix = "plone.app.querystring"
+
+INVALID_OPERATION = 'Invalid operation %s for criterion: %s'
+
+# Converters
+
+class CriterionConverter(object):
+
+    # Last part of the code for the dotted operation method,
+    # e.g. 'string.contains'.
+    operator_code = ''
+    # alternative code, possibly used if the first code does not work.
+    alt_operator_code = ''
+
+    def get_query_value(self, value, index, criterion):
+        # value may contain a query and some parameters, but in the
+        # simple case it is simply a value.
+        return value
+
+    def get_operation(self, value, index, criterion):
+        # Get dotted operation method.  This may depend on value.
+        return "%s.operation.%s" % (prefix, self.operator_code)
+
+    def get_alt_operation(self, value, index, criterion):
+        # Get dotted operation method.  This may depend on value.
+        return "%s.operation.%s" % (prefix, self.alt_operator_code)
+
+    def is_index_known(self, registry, index):
+        # Is the index registered as criterion index?
+        key = '%s.field.%s' % (prefix, index)
+        try:
+            registry.get(key)
+        except KeyError:
+            logger.error("Index %s is no criterion index. Registry gives "
+                         "KeyError: %s", index, key)
+            return False
+        return True
+
+    def is_index_enabled(self, registry, index):
+        # Is the index enabled as criterion index?
+        key = '%s.field.%s' % (prefix, index)
+        index_data = registry.get(key)
+        if index_data.get('enabled'):
+            return True
+        logger.warn("Index %s is not enabled as criterion index. ", index)
+        return False
+
+    def switch_type_to_portal_type(self, value, criterion):
+        # 'portal_type' is the object id of the FTI in portal_types.
+        # 'Type' is the title of that object.
+        # For example:
+        # - portal_type 'Document' has Type 'Page'.
+        # - portal_type 'Topic' has Type 'Collection (old)'.
+        if isinstance(value, dict):
+            values = value.get('query', [])
+        else:
+            values = value
+        if not values:
+            return value
+        new_values = []
+        ttool = getToolByName(criterion, 'portal_types')
+        type_to_portal_type = {}
+        portal_types = ttool.objectIds()
+        for portal_type, Type in ttool.listTypeTitles().items():
+            type_to_portal_type[Type] = portal_type
+        for Type in values:
+            portal_type = type_to_portal_type.get(Type)
+            if not portal_type:
+                if Type in portal_types:
+                    portal_type = Type
+                else:
+                    logger.warn("Cannot switch Type %r to portal_type.", Type)
+                    continue
+            new_values.append(portal_type)
+        new_values = tuple(new_values)
+        if isinstance(value, dict):
+            value['query'] = new_values
+        else:
+            value = new_values
+        return value
+
+    def is_operation_valid(self, registry, operation):
+        # Check that the operation exists.
+        op_info = registry.get(operation)
+        if op_info is None:
+            logger.error("Operation %r is not defined.", operation)
+            return False
+        op_function_name = op_info.get('operation')
+        try:
+            resolve(op_function_name)
+        except ImportError:
+            logger.error("ImportError for operation %r: %s",
+                         operation, op_function_name)
+            return False
+        return True
+
+    def get_valid_operation(self, registry, index, value, criterion):
+        key = '%s.field.%s.operations' % (prefix, index)
+        operations = registry.get(key)
+        operation = self.get_operation(value, index, criterion)
+        if operation not in operations:
+            operation = self.get_alt_operation(value, index, criterion)
+            if operation not in operations:
+                return
+        if self.is_operation_valid(registry, operation):
+            return operation
+
+    def add_to_formquery(self, formquery, index, operation, query_value):
+        row = {'i': index,
+               'o': operation}
+        if query_value is not None:
+            row['v'] = query_value
+        formquery.append(row)
+
+    def __call__(self, formquery, criterion, registry):
+        criteria = criterion.getCriteriaItems()
+        if not criteria:
+            logger.warn("Ignoring empty criterion %s.", criterion)
+            return
+        for index, value in criteria:
+            # Check if the index is known and enabled as criterion index.
+            if index == 'Type':
+                # Try to replace Type by portal_type
+                index = 'portal_type'
+                value = self.switch_type_to_portal_type(value, criterion)
+            if not self.is_index_known(registry, index):
+                logger.info("Index %s not known in registry.", index)
+                continue
+            self.is_index_enabled(registry, index)
+            # TODO: what do we do when this is False?  Raise an
+            # Exception?  Continue processing the index and value
+            # anyway, now that a warning is logged?  Continue with the
+            # next criteria item?
+
+            # Get the operation method.
+            operation = self.get_valid_operation(
+                registry,
+                index,
+                value,
+                criterion
+            )
+            if not operation:
+                logger.error(INVALID_OPERATION % (operation, criterion))
+                # TODO: raise an Exception?
+                continue
+
+            # Get the value that we will query for.
+            query_value = self.get_query_value(value, index, criterion)
+
+            # Add a row to the form query.
+            self.add_to_formquery(formquery, index, operation, query_value)
+
+
+class ATDateCriteriaConverter(CriterionConverter):
+    """Handle date criteria.
+
+    Note that there is also ATDateRangeCriterion, which is much
+    simpler as it just has two dates.
+
+    In our case we have these valid operations:
+
+    ['plone.app.querystring.operation.date.lessThan',
+     'plone.app.querystring.operation.date.largerThan',
+     'plone.app.querystring.operation.date.between',
+     'plone.app.querystring.operation.date.lessThanRelativeDate',
+     'plone.app.querystring.operation.date.largerThanRelativeDate',
+     'plone.app.querystring.operation.date.today',
+     'plone.app.querystring.operation.date.beforeToday',
+     'plone.app.querystring.operation.date.afterToday']
+
+    This code is based on the getCriteriaItems method from
+    Products/ATContentTypes/criteria/date.py.  We check the field
+    values ourselves instead of translating the values back and forth.
+    """
+
+    def __call__(self, formquery, criterion, registry):
+        if criterion.value is None:
+            logger.warn("Ignoring empty criterion %s.", criterion)
+            return
+        field = criterion.Field()
+        value = criterion.Value()
+
+        # Check if the index is known and enabled as criterion index.
+        if not self.is_index_known(registry, field):
+            return
+        self.is_index_enabled(registry, field)
+
+        # Negate the value for 'old' days
+        if criterion.getDateRange() == '-':
+            value = -value
+
+        date = DateTime() + value
+
+        # Get the possible operation methods.
+        key = '%s.field.%s.operations' % (prefix, field)
+        operations = registry.get(key)
+
+        def add_row(operation, value=None):
+            if operation not in operations:
+                # TODO just ignore it?
+                raise ValueError(INVALID_OPERATION % (operation, criterion))
+            if not self.is_operation_valid(registry, operation):
+                # TODO just ignore it?
+                raise ValueError(INVALID_OPERATION % (operation, criterion))
+            # Add a row to the form query.
+            row = {'i': field,
+                   'o': operation}
+            if value is not None:
+                row['v'] = value
+            formquery.append(row)
+
+        operation = criterion.getOperation()
+        if operation == 'within_day':
+            if date.isCurrentDay():
+                new_operation = "%s.operation.date.today" % prefix
+                add_row(new_operation)
+                return
+            date_range = (date.earliestTime(), date.latestTime())
+            new_operation = "%s.operation.date.between" % prefix
+            add_row(new_operation, date_range)
+            return
+        if operation == 'more':
+            if value != 0:
+                new_operation = ("{0}.operation.date."
+                                 "largerThanRelativeDate".format(prefix))
+                add_row(new_operation, value)
+                return
+            else:
+                new_operation = "{0}.operation.date.afterToday".format(prefix)
+                add_row(new_operation)
+                return
+        if operation == 'less':
+            if value != 0:
+                new_operation = ("{0}.operation.date."
+                                 "lessThanRelativeDate".format(prefix))
+                add_row(new_operation, value)
+                return
+            else:
+                new_operation = "{0}.operation.date.beforeToday".format(prefix)
+                add_row(new_operation)
+                return
+
+
+class ATSimpleStringCriterionConverter(CriterionConverter):
+    operator_code = 'string.contains'
+    # review_state could be a string criterion, but should become a selection.
+    alt_operator_code = 'selection.is'
+
+
+class ATCurrentAuthorCriterionConverter(CriterionConverter):
+    operator_code = 'string.currentUser'
+
+
+class ATSelectionCriterionConverter(CriterionConverter):
+    operator_code = 'selection.is'
+
+    def get_query_value(self, value, index, criterion):
+        values = value['query']
+        if value.get('operator') == 'and' and len(values) > 1:
+            logger.warn("Cannot handle selection operator 'and'. Using 'or'. "
+                        "%r", value)
+        values = value['query']
+        # Special handling for portal_type=Topic.
+        if index == 'portal_type' and 'Topic' in values:
+            values = list(values)
+            values[values.index('Topic')] = 'Collection'
+            values = tuple(values)
+        return values
+
+
+class ATListCriterionConverter(ATSelectionCriterionConverter):
+    pass
+
+
+class ATReferenceCriterionConverter(ATSelectionCriterionConverter):
+    # Note: the new criterion is disabled by default.  Also, it
+    # needs the _referenceIs function in the plone.app.querystring
+    # queryparser and that function is not defined.
+    operator_code = 'reference.is'
+
+
+class ATPathCriterionConverter(CriterionConverter):
+    operator_code = 'string.path'
+
+    def get_query_value(self, value, index, criterion):
+        raw = criterion.getRawValue()
+        if not raw:
+            return
+        # Is this a recursive query?  Could check depth in the value
+        # actually, but Recurse is the canonical way.  Also, the only
+        # possible values for depth are -1 and 1.
+        if not criterion.Recurse():
+            for index, path in enumerate(raw):
+                raw[index] = path + '::1'
+        return raw
+
+    def add_to_formquery(self, formquery, index, operation, query_value):
+        if query_value is None:
+            return
+        for value in query_value:
+            row = {'i': index,
+                   'o': operation,
+                   'v': value}
+            formquery.append(row)
+
+
+class ATBooleanCriterionConverter(CriterionConverter):
+
+    def get_operation(self, value, index, criterion):
+        # Get dotted operation method.
+        # value is one of these beauties:
+        # value = [1, True, '1', 'True']
+        # value = [0, '', False, '0', 'False', None, (), [], {}, MV]
+        if True in value:
+            code = 'isTrue'
+        elif False in value:
+            code = 'isFalse'
+        else:
+            logger.warn("Unknown value for boolean criterion. "
+                        "Falling back to True. %r", value)
+            code = 'isTrue'
+        return "%s.operation.boolean.%s" % (prefix, code)
+
+    def __call__(self, formquery, criterion, registry):
+        criteria = criterion.getCriteriaItems()
+        if not criteria:
+            return
+        for index, value in criteria:
+            if index == 'is_folderish':
+                fieldname = 'isFolderish'
+            elif index == 'is_default_page':
+                fieldname = 'isDefaultPage'
+            else:
+                fieldname = index
+            # Check if the index is known and enabled as criterion index.
+            if not self.is_index_known(registry, fieldname):
+                continue
+            self.is_index_enabled(registry, fieldname)
+            # Get the operation method.
+            operation = self.get_valid_operation(
+                registry, fieldname, value, criterion)
+            if not operation:
+                logger.error(INVALID_OPERATION % (operation, criterion))
+                # TODO: raise an Exception?
+                continue
+            # Add a row to the form query.
+            row = {'i': index,
+                   'o': operation}
+            formquery.append(row)
+
+
+class ATDateRangeCriterionConverter(CriterionConverter):
+    operator_code = 'date.between'
+
+    def get_query_value(self, value, index, criterion):
+        return value['query']
+
+
+class ATPortalTypeCriterionConverter(CriterionConverter):
+    operator_code = 'selection.is'
+
+    def get_query_value(self, value, index, criterion):
+        # Special handling for portal_type=Topic.
+        if 'Topic' in value:
+            value = list(value)
+            value[value.index('Topic')] = 'Collection'
+            value = tuple(value)
+        return value
+
+
+class ATRelativePathCriterionConverter(CriterionConverter):
+    # We also have path.isWithinRelative, but its function is not defined.
+    operator_code = 'string.relativePath'
+
+    def get_query_value(self, value, index, criterion):
+        if not criterion.Recurse():
+            logger.warn("Cannot handle non-recursive path search. "
+                        "Allowing recursive search. %r", value)
+        return criterion.getRelativePath()
+
+
+class ATSimpleIntCriterionConverter(CriterionConverter):
+    # Also available: int.lessThan, int.largerThan.
+    operator_code = 'int.is'
+
+    def get_operation(self, value, index, criterion):
+        # Get dotted operation method.
+        direction = value.get('range')
+        if not direction:
+            code = 'is'
+        elif direction == 'min':
+            code = 'largerThan'
+        elif direction == 'max':
+            code = 'lessThan'
+        elif direction == 'min:max':
+            logger.warn("min:max direction not supported for integers. %r",
+                        value)
+            return
+        else:
+            logger.warn("Unknown direction for integers. %r", value)
+            return
+        return "{0}.operation.int.{1}".format(prefix, code)
+
+    def get_query_value(self, value, index, criterion):
+        if isinstance(value['query'], tuple):
+            logger.warn("More than one integer is not supported. %r", value)
+            return
+        return value['query']
+
+
+class TopicMigrator(InplaceCMFItemMigrator, ReferenceMigrator):
+    """Migrate Topics to Collections. Existing subtopics will be lost.
+
+    The only difference to the migration below is the base-class
+    (InplaceCMFItemMigrator instead of InplaceCMFFolderMigrator).
+    """
+    src_portal_type = 'Topic'
+    src_meta_type = 'ATTopic'
+    dst_portal_type = dst_meta_type = 'Collection'
+    view_methods_mapping = {
+        'folder_listing': 'standard_view',
+        'folder_summary_view': 'summary_view',
+        'folder_full_view': 'all_content',
+        'folder_tabular_view': 'tabular_view',
+        'atct_album_view': 'thumbnail_view',
+        'atct_topic_view': 'standard_view',
+        }
+
+    @property
+    def registry(self):
+        return self.kwargs['registry']
+
+    def last_migrate_layout(self):
+        """Migrate the layout (view method).
+
+        This needs to be done last, as otherwise our changes in
+        migrate_criteria may get overriden by a later call to
+        migrate_properties.
+        """
+        if self.old.getCustomView():
+            # Previously, the atct_topic_view had logic for showing
+            # the results in a list or in tabular form.  If
+            # getCustomView is True, this means the new object should
+            # use the tabular view.
+            self.new.setLayout('tabular_view')
+            return
+
+        old_layout = self.old.getLayout() or getattr(self.old, 'layout', None)
+        layout = self.view_methods_mapping.get(old_layout)
+        if layout:
+            self.new.setLayout(layout)
+
+    def beforeChange_criteria(self):
+        """Store the criteria of the old Topic.
+
+        Store the info on the migrator and restore the values in the
+        migrate_criteria method.
+        """
+        self._collection_sort_reversed = None
+        self._collection_sort_on = None
+        self._collection_query = None
+        path = '/'.join(self.old.getPhysicalPath())
+        logger.info("Migrating %s at %s", self.src_portal_type, path)
+        # Get the old criteria.
+        # See also Products.ATContentTypes.content.topic.buildQuery
+        criteria = self.old.listCriteria()
+        logger.debug("Old criteria for %s: %r", path,
+                     [(crit, crit.getCriteriaItems()) for crit in criteria])
+        formquery = []
+        for criterion in criteria:
+            type_ = criterion.__class__.__name__
+            if type_ == 'ATSortCriterion':
+                # Sort order and direction are now stored in the Collection.
+                self._collection_sort_reversed = criterion.getReversed()
+                self._collection_sort_on = criterion.Field()
+                logger.debug("Sort on %r, reverse: %s.",
+                             self._collection_sort_on,
+                             self._collection_sort_reversed)
+                continue
+
+            converter = CONVERTERS.get(type_)
+            if converter is None:
+                msg = 'Unsupported criterion %s' % type_
+                logger.error(msg)
+                raise ValueError(msg)
+            converter(formquery, criterion, self.registry)
+
+        logger.debug("New query for %s: %r", path, formquery)
+        self._collection_query = formquery
+
+    def migrate_criteria(self):
+        """Migrate old style to new style criteria.
+
+        Plus handling for some special fields.
+        """
+        # The old Topic has boolean limitNumber and integer itemCount,
+        # where the new Collection only has limit.
+        adapted = ICollection(self.new)
+        if self.old.getLimitNumber():
+            adapted.limit = self.old.getItemCount()
+        adapted.customViewFields = self.old.getCustomViewFields()
+
+        # Get the old data stored by the beforeChange_criteria method.
+        if self._collection_sort_reversed is not None:
+            adapted.sort_reversed = self._collection_sort_reversed
+        if self._collection_sort_on is not None:
+            adapted.sort_on = self._collection_sort_on
+        if self._collection_query is not None:
+            adapted.query = self._collection_query
+
+
+    def migrate_atctmetadata(self):
+        field = self.old.getField('excludeFromNav')
+        self.new.exclude_from_nav = field.get(self.old)
+
+    def migrate_at_uuid(self):
+        """Migrate AT universal uid
+        """
+        uid = self.UID
+        if uid and queryAdapter(self.new, IMutableUUID):
+            IMutableUUID(self.new).set(str(uid))
+
+
+class FolderishTopicMigrator(InplaceCMFFolderMigrator, ReferenceMigrator):
+    """Migrate Topics and Subtopics to folderish collections.
+
+    The only difference to the migration above is the base-class
+    (InplaceCMFFolderMigrator instead of InplaceCMFItemMigrator).
+    """
+    src_portal_type = 'Topic'
+    src_meta_type = 'ATTopic'
+    dst_portal_type = dst_meta_type = 'Collection'
+    view_methods_mapping = {
+        'folder_listing': 'standard_view',
+        'folder_summary_view': 'summary_view',
+        'folder_full_view': 'all_content',
+        'folder_tabular_view': 'tabular_view',
+        'atct_album_view': 'thumbnail_view',
+        'atct_topic_view': 'standard_view',
+        }
+
+    @property
+    def registry(self):
+        return self.kwargs['registry']
+
+    def last_migrate_layout(self):
+        """Migrate the layout (view method).
+
+        This needs to be done last, as otherwise our changes in
+        migrate_criteria may get overriden by a later call to
+        migrate_properties.
+        """
+        if self.old.getCustomView():
+            # Previously, the atct_topic_view had logic for showing
+            # the results in a list or in tabular form.  If
+            # getCustomView is True, this means the new object should
+            # use the tabular view.
+            self.new.setLayout('tabular_view')
+            return
+
+        old_layout = self.old.getLayout() or getattr(self.old, 'layout', None)
+        layout = self.view_methods_mapping.get(old_layout)
+        if layout:
+            self.new.setLayout(layout)
+
+    def beforeChange_criteria(self):
+        """Store the criteria of the old Topic.
+
+        Store the info on the migrator and restore the values in the
+        migrate_criteria method.
+        """
+        self._collection_sort_reversed = None
+        self._collection_sort_on = None
+        self._collection_query = None
+        path = '/'.join(self.old.getPhysicalPath())
+        logger.info("Migrating %s at %s", self.src_portal_type, path)
+        # Get the old criteria.
+        # See also Products.ATContentTypes.content.topic.buildQuery
+        criteria = self.old.listCriteria()
+        logger.debug("Old criteria for %s: %r", path,
+                     [(crit, crit.getCriteriaItems()) for crit in criteria])
+        formquery = []
+        for criterion in criteria:
+            type_ = criterion.__class__.__name__
+            if type_ == 'ATSortCriterion':
+                # Sort order and direction are now stored in the Collection.
+                self._collection_sort_reversed = criterion.getReversed()
+                self._collection_sort_on = criterion.Field()
+                logger.debug("Sort on %r, reverse: %s.",
+                             self._collection_sort_on,
+                             self._collection_sort_reversed)
+                continue
+
+            converter = CONVERTERS.get(type_)
+            if converter is None:
+                msg = 'Unsupported criterion %s' % type_
+                logger.error(msg)
+                raise ValueError(msg)
+            converter(formquery, criterion, self.registry)
+
+        logger.debug("New query for %s: %r", path, formquery)
+        self._collection_query = formquery
+
+    def migrate_criteria(self):
+        """Migrate old style to new style criteria.
+
+        Plus handling for some special fields.
+        """
+        # The old Topic has boolean limitNumber and integer itemCount,
+        # where the new Collection only has limit.
+        adapted = ICollection(self.new)
+        if self.old.getLimitNumber():
+            adapted.limit = self.old.getItemCount()
+        adapted.customViewFields = self.old.getCustomViewFields()
+
+        # Get the old data stored by the beforeChange_criteria method.
+        if self._collection_sort_reversed is not None:
+            adapted.sort_reversed = self._collection_sort_reversed
+        if self._collection_sort_on is not None:
+            adapted.sort_on = self._collection_sort_on
+        if self._collection_query is not None:
+            adapted.query = self._collection_query
+
+
+    def migrate_atctmetadata(self):
+        field = self.old.getField('excludeFromNav')
+        self.new.exclude_from_nav = field.get(self.old)
+
+    def migrate_at_uuid(self):
+        """Migrate AT universal uid
+        """
+        uid = self.UID
+        if uid and queryAdapter(self.new, IMutableUUID):
+            IMutableUUID(self.new).set(str(uid))
+
+
+def migrate_topics(portal):
+    """Migrate ATContentTypes Topics to plone.app.contenttypes Collections.
+
+    This could also be used as upgrade step.
+    """
+    # Parse the registry to get allowed operations and pass it to the
+    # migrator.
+    reg = getUtility(IRegistry)
+    reader = IQuerystringRegistryReader(reg)
+    registry = reader.parseRegistry()
+    # select migrator based on the base-class of collections
+    fti = portal.portal_types['Collection']
+    if fti.content_meta_type == "Dexterity Item":
+        migrator = TopicMigrator
+    elif fti.content_meta_type == "Dexterity Container":
+        migrator = FolderishTopicMigrator
+    walker = CustomQueryWalker(portal, migrator)(registry=registry)
+    return walker
+
+
+CONVERTERS = {
+    # Create an instance of each converter.
+    'ATBooleanCriterion': ATBooleanCriterionConverter(),
+    'ATCurrentAuthorCriterion': ATCurrentAuthorCriterionConverter(),
+    'ATDateCriteria': ATDateCriteriaConverter(),
+    'ATDateRangeCriterion': ATDateRangeCriterionConverter(),
+    'ATListCriterion': ATListCriterionConverter(),
+    'ATPathCriterion': ATPathCriterionConverter(),
+    'ATPortalTypeCriterion': ATPortalTypeCriterionConverter(),
+    'ATReferenceCriterion': ATReferenceCriterionConverter(),
+    'ATRelativePathCriterion': ATRelativePathCriterionConverter(),
+    'ATSelectionCriterion': ATSelectionCriterionConverter(),
+    'ATSimpleIntCriterion': ATSimpleIntCriterionConverter(),
+    'ATSimpleStringCriterion': ATSimpleStringCriterionConverter(),
+    }
diff --git a/plone/app/contenttypes/migration/utils.py b/plone/app/contenttypes/migration/utils.py
index d007bd0..8959d2f 100644
--- a/plone/app/contenttypes/migration/utils.py
+++ b/plone/app/contenttypes/migration/utils.py
@@ -100,7 +100,8 @@ def installTypeIfNeeded(type_name):
     if IDexterityFTI.providedBy(fti):
         # the dx-type is already installed
         return
-    tt.manage_delObjects(type_name)
+    if fti:
+        tt.manage_delObjects(type_name)
     tt.manage_addTypeInformation('Dexterity FTI', id=type_name)
     dx_fti = tt.getTypeInfo(type_name)
     ps = getToolByName(portal, 'portal_setup')
diff --git a/plone/app/contenttypes/migration/vocabularies.py b/plone/app/contenttypes/migration/vocabularies.py
index 905812b..4b14d1d 100644
--- a/plone/app/contenttypes/migration/vocabularies.py
+++ b/plone/app/contenttypes/migration/vocabularies.py
@@ -6,12 +6,14 @@
 from Products.ATContentTypes.interfaces.image import IATImage
 from Products.ATContentTypes.interfaces.link import IATLink
 from Products.ATContentTypes.interfaces.news import IATNewsItem
+from Products.ATContentTypes.interfaces.topic import IATTopic
 from Products.CMFCore.utils import getToolByName
 from plone.app.blob.interfaces import IATBlobFile
 from plone.app.blob.interfaces import IATBlobImage
 from plone.app.contenttypes import _
 from plone.app.contenttypes.migration import migration
 from plone.app.contenttypes.migration.utils import isSchemaExtended
+from plone.app.contenttypes.migration.topics import migrate_topics
 from zope.interface import implements
 from zope.schema.interfaces import IVocabularyFactory
 from zope.schema.vocabulary import SimpleVocabulary
@@ -93,6 +95,13 @@
         'type_name': 'File',
         'old_meta_type': 'ATBlob',
     },
+    "Topic": {
+        'iface': IATTopic,
+        'migrator': migrate_topics,
+        'extended_fields': [],
+        'type_name': 'Collection',
+        'old_meta_type': 'ATTopic',
+    },
 }
 
 if HAS_APP_COLLECTION:
diff --git a/plone/app/contenttypes/testing.py b/plone/app/contenttypes/testing.py
index aa4a2a3..bc0bb91 100644
--- a/plone/app/contenttypes/testing.py
+++ b/plone/app/contenttypes/testing.py
@@ -86,6 +86,7 @@ def setUpZope(self, app, configurationContext):
         z2.installProduct(app, 'Products.Archetypes')
         z2.installProduct(app, 'Products.ATContentTypes')
         z2.installProduct(app, 'plone.app.blob')
+
         # prepare installing plone.app.collection
         try:
             pkg_resources.get_distribution('plone.app.collection')
@@ -120,6 +121,9 @@ def setUpPloneSite(self, portal):
         if 'Products.ATContentTypes:default' in profiles:
             applyProfile(portal, 'Products.ATContentTypes:default')
 
+        # enable old Topic
+        portal.portal_types.Topic.global_allow = True
+
         # install plone.app.collections manually if profile is available
         # (this is only needed for Plone >= 5)
         if 'plone.app.collection:default' in profiles:
diff --git a/plone/app/contenttypes/tests/test_migration.py b/plone/app/contenttypes/tests/test_migration.py
index 2392381..3b27a63 100644
--- a/plone/app/contenttypes/tests/test_migration.py
+++ b/plone/app/contenttypes/tests/test_migration.py
@@ -1147,6 +1147,7 @@ def test_migrate_xx_functions(self):
             migrate_folders,
             migrate_events,
         )
+        from plone.app.contenttypes.migration.topics import migrate_topics
 
         # create all content types
         self.portal.invokeFactory('Document', 'document')
@@ -1160,6 +1161,7 @@ def test_migrate_xx_functions(self):
         self.createATCTBlobNewsItem('blobnewsitem')
         self.portal.invokeFactory('Folder', 'folder')
         self.portal.invokeFactory('Event', 'event')
+        self.portal.invokeFactory('Topic', 'topic')
 
         # migrate all
         applyProfile(self.portal, 'plone.app.contenttypes:default')
@@ -1174,6 +1176,7 @@ def test_migrate_xx_functions(self):
         migrate_blobnewsitems(self.portal)
         migrate_folders(self.portal)
         migrate_events(self.portal)
+        migrate_topics(self.portal)
 
         # assertions
         cat = self.catalog
@@ -1182,7 +1185,7 @@ def test_migrate_xx_functions(self):
         dx_contents = cat(object_provides='plone.dexterity'
                           '.interfaces.IDexterityContent')
         self.assertEqual(len(at_contents), 0)
-        self.assertEqual(len(dx_contents), 11)
+        self.assertEqual(len(dx_contents), 12)
 
     def test_warning_for_uneditable_content(self):
         set_browserlayer(self.request)
diff --git a/plone/app/contenttypes/tests/test_migration_topic.py b/plone/app/contenttypes/tests/test_migration_topic.py
new file mode 100644
index 0000000..0a07c00
--- /dev/null
+++ b/plone/app/contenttypes/tests/test_migration_topic.py
@@ -0,0 +1,750 @@
+# -*- coding: utf-8 -*-
+from DateTime import DateTime
+from Products.CMFCore.utils import getToolByName
+from plone.app.contenttypes.migration.topics import migrate_topics
+from plone.app.contenttypes.behaviors.collection import ICollection
+from plone.app.contenttypes.testing import \
+    PLONE_APP_CONTENTTYPES_MIGRATION_TESTING
+from plone.dexterity.content import Container
+from plone.app.testing import applyProfile
+from plone.app.testing import login
+from plone.dexterity.interfaces import IDexterityFTI
+from zope.component import queryUtility
+from zope.interface import implementer
+
+import unittest
+
+
+@implementer(ICollection)
+class FolderishCollection(Container):
+   """Test subclass for folderish ``Collections``.
+   """
+
+
+class MigrateTopicsIntegrationTest(unittest.TestCase):
+
+    layer = PLONE_APP_CONTENTTYPES_MIGRATION_TESTING
+
+    def setUp(self):
+        self.portal = self.layer['portal']
+        self.request = self.layer['request']
+        self.request['ACTUAL_URL'] = self.portal.absolute_url()
+        self.request['URL'] = self.portal.absolute_url()
+        self.catalog = getToolByName(self.portal, "portal_catalog")
+        self.portal.acl_users.userFolderAddUser('admin',
+                                                'secret',
+                                                ['Manager'],
+                                                [])
+        login(self.portal, 'admin')
+        self.portal.portal_workflow.setDefaultChain(
+            "simple_publication_workflow")
+        self.portal.invokeFactory("Topic", "topic", title="Topic")
+        self.portal.invokeFactory("Folder", "folder", title="Folder")
+
+    def run_migration(self):
+        migrate_topics(self.portal)
+
+    def add_criterion(self, index, criterion, value=None):
+        name = '%s_%s' % (index, criterion)
+        self.portal.topic.addCriterion(index, criterion)
+        crit = self.portal.topic.getCriterion(name)
+        if value is not None:
+            crit.setValue(value)
+        return crit
+
+    def test_migrate_simple_topic(self):
+        self.assertEqual(self.portal.topic.portal_type, 'Topic')
+        self.assertEqual(self.portal.topic.getLayout(), 'atct_topic_view')
+        self.assertEqual(self.portal.topic.getLimitNumber(), False)
+        self.assertEqual(self.portal.topic.getItemCount(), 0)
+        self.assertEqual(self.portal.topic.getCustomViewFields(), ('Title',))
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        new = ICollection(self.portal.topic)
+        self.assertEqual(self.portal.topic.portal_type, 'Collection')
+        self.assertEqual(self.portal.topic.getLayout(), 'standard_view')
+        self.assertEqual(new.sort_on, None)
+        self.assertEqual(new.sort_reversed, None)
+        self.assertEqual(new.limit, 1000)
+        self.assertEqual(new.customViewFields, ('Title',))
+
+    def test_migrate_topic_fields(self):
+        self.portal.topic.setText('<p>Hello</p>')
+        self.portal.topic.setLimitNumber(True)
+        self.portal.topic.setItemCount(42)
+        self.portal.topic.setCustomViewFields(('Title', 'Type'))
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        new = ICollection(self.portal.topic)
+        self.assertEqual(self.portal.topic.portal_type, 'Collection')
+        self.assertEqual(new.limit, 42)
+        self.assertEqual(new.customViewFields, ('Title', 'Type'))
+
+    def test_migrate_layout(self):
+        self.portal.topic.setLayout('folder_summary_view')
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        self.assertEqual(self.portal.topic.getLayout(), 'summary_view')
+
+    def test_migrate_customView(self):
+        self.portal.topic.setCustomView(True)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        self.assertEqual(self.portal.topic.getLayout(), 'tabular_view')
+
+    def test_migrate_nested_topic(self):
+        self.portal.portal_types['Topic'].filter_content_types = False
+        self.portal.topic.invokeFactory("Topic", "subtopic", title="Sub Topic")
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        fti = queryUtility(IDexterityFTI, name='Collection')
+        # switch our a custom folderish base-class for collections
+        # we need to use _updateProperty because this also refreshes
+        # the content_meta_type attribute when klass has changed
+        fti._updateProperty(
+            'klass',
+            'plone.app.contenttypes.tests.test_migration_topic.FolderishCollection')
+        fti._updateProperty('allowed_content_types', ['Document', 'Folder'])
+        fti._updateProperty('filter_content_types', False)
+        self.run_migration()
+        self.assertEqual(self.portal.topic.portal_type, 'Collection')
+        self.assertEqual(self.portal.topic.subtopic.portal_type, 'Collection')
+
+    def test_ATSimpleStringCriterion(self):
+        self.add_criterion('SearchableText', 'ATSimpleStringCriterion', 'bar')
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        self.assertEqual(
+            self.portal.topic.query,
+            [{'i': 'SearchableText',
+              'o': 'plone.app.querystring.operation.string.contains',
+              'v': 'bar'}]
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSimpleStringCriterionToSelection(self):
+        # Some string criterions really should be selection criterions.
+        self.add_criterion(
+            'review_state',
+            'ATSimpleStringCriterion', 'published'
+        )
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        self.assertEqual(
+            self.portal.topic.query,
+            [{'i': 'review_state',
+              'o': 'plone.app.querystring.operation.selection.is',
+              'v': 'published'}]
+        )
+
+    def test_ATDateCriteriaPast(self):
+        # More than 5 days in the past:
+        crit = self.add_criterion('created', 'ATFriendlyDateCriteria', 5)
+        crit.setOperation('more')
+        crit.setDateRange('-')
+        # Less than 5 days in the past:
+        crit = self.add_criterion('effective', 'ATFriendlyDateCriteria', 5)
+        crit.setOperation('less')
+        crit.setDateRange('-')
+        # The next two are logically a bit weird.
+        # More than 0 days in the past is historically interpreted as: after
+        # today.
+        crit = self.add_criterion('expires', 'ATFriendlyDateCriteria', 0)
+        crit.setOperation('more')
+        crit.setDateRange('-')
+        # Less than 0 days in the past is historically interpreted as: before
+        # today.
+        crit = self.add_criterion('modified', 'ATFriendlyDateCriteria', 0)
+        crit.setOperation('less')
+        crit.setDateRange('-')
+
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        query = self.portal.topic.query
+        self.assertEqual(len(query), 4)
+
+        self.assertEqual(query[0]['i'], 'created')
+        self.assertEqual(
+            query[0]['o'],
+            'plone.app.querystring.operation.date.largerThanRelativeDate'
+        )
+        self.assertEqual(query[0]['v'], -5)
+
+        self.assertEqual(query[1]['i'], 'effective')
+        self.assertEqual(
+            query[1]['o'],
+            'plone.app.querystring.operation.date.lessThanRelativeDate'
+        )
+        self.assertEqual(query[1]['v'], -5)
+
+        self.assertEqual(query[2]['i'], 'expires')
+        self.assertEqual(
+            query[2]['o'],
+            'plone.app.querystring.operation.date.afterToday'
+        )
+        self.assertTrue('v' not in query[2].keys())
+
+        self.assertEqual(query[3]['i'], 'modified')
+        self.assertEqual(
+            query[3]['o'],
+            'plone.app.querystring.operation.date.beforeToday'
+        )
+        self.assertTrue('v' not in query[3].keys())
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATDateCriteriaFuture(self):
+        # More than 5 days in the future:
+        crit = self.add_criterion('created', 'ATFriendlyDateCriteria', 5)
+        crit.setOperation('more')
+        crit.setDateRange('+')
+        # Less than 5 days in the future:
+        crit = self.add_criterion('effective', 'ATFriendlyDateCriteria', 5)
+        crit.setOperation('less')
+        crit.setDateRange('+')
+        # More than 0 days in the future: after today.
+        crit = self.add_criterion('expires', 'ATFriendlyDateCriteria', 0)
+        crit.setOperation('more')
+        crit.setDateRange('+')
+        # Less than 0 days in the future: before today.
+        crit = self.add_criterion('modified', 'ATFriendlyDateCriteria', 0)
+        crit.setOperation('less')
+        crit.setDateRange('+')
+
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        query = self.portal.topic.query
+        self.assertEqual(len(query), 4)
+
+        self.assertEqual(query[0]['i'], 'created')
+        self.assertEqual(
+            query[0]['o'],
+            'plone.app.querystring.operation.date.largerThanRelativeDate'
+        )
+        self.assertEqual(query[0]['v'], 5)
+
+        self.assertEqual(query[1]['i'], 'effective')
+        self.assertEqual(
+            query[1]['o'],
+            'plone.app.querystring.operation.date.lessThanRelativeDate'
+        )
+        self.assertTrue(query[1]['v'], 5)
+
+        self.assertEqual(query[2]['i'], 'expires')
+        self.assertEqual(
+            query[2]['o'],
+            'plone.app.querystring.operation.date.afterToday'
+        )
+        self.assertTrue('v' not in query[2].keys())
+
+        self.assertEqual(query[3]['i'], 'modified')
+        self.assertEqual(
+            query[3]['o'],
+            'plone.app.querystring.operation.date.beforeToday'
+        )
+        self.assertTrue('v' not in query[3].keys())
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATDateCriteriaExactDay(self):
+        # 5 days ago:
+        crit = self.add_criterion('created', 'ATFriendlyDateCriteria', 5)
+        crit.setOperation('within_day')
+        crit.setDateRange('-')
+        # 5 days from now:
+        crit = self.add_criterion('effective', 'ATFriendlyDateCriteria', 5)
+        crit.setOperation('within_day')
+        crit.setDateRange('+')
+        # past or future does not matter if the day is today.
+        # today minus
+        crit = self.add_criterion('expires', 'ATFriendlyDateCriteria', 0)
+        crit.setOperation('within_day')
+        crit.setDateRange('-')
+        # today plus
+        crit = self.add_criterion('modified', 'ATFriendlyDateCriteria', 0)
+        crit.setOperation('within_day')
+        crit.setDateRange('+')
+
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        query = self.portal.topic.query
+        time2 = DateTime()
+        self.assertEqual(len(query), 4)
+
+        self.assertEqual(query[0]['i'], 'created')
+        self.assertEqual(
+            query[0]['o'],
+            'plone.app.querystring.operation.date.between'
+        )
+        self.assertEqual(
+            query[0]['v'],
+            ((time2 - 5).earliestTime(), (time2 - 5).latestTime())
+        )
+
+        self.assertEqual(query[1]['i'], 'effective')
+        self.assertEqual(
+            query[1]['o'],
+            'plone.app.querystring.operation.date.between'
+        )
+        self.assertEqual(
+            query[1]['v'],
+            ((time2 + 5).earliestTime(), (time2 + 5).latestTime())
+        )
+
+        self.assertEqual(query[2]['i'], 'expires')
+        self.assertEqual(
+            query[2]['o'],
+            'plone.app.querystring.operation.date.today'
+        )
+        self.assertFalse('v' in query[2].keys())
+
+        self.assertEqual(query[3]['i'], 'modified')
+        self.assertEqual(
+            query[3]['o'],
+            'plone.app.querystring.operation.date.today'
+        )
+        self.assertFalse('v' in query[3].keys())
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATCurrentAuthorCriterion(self):
+        self.add_criterion('Creator', 'ATCurrentAuthorCriterion')
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        self.assertEqual(
+            self.portal.topic.query,
+            [{'i': 'Creator',
+              'o': 'plone.app.querystring.operation.string.currentUser',
+              'v': 'admin'}]
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATListCriterion(self):
+        # The new-style queries do not currently offer the possibility
+        # to choose if the given values should be joined with 'or' or
+        # 'and'.  Default is 'or'.
+        crit = self.add_criterion('Subject', 'ATListCriterion', ('foo', 'bar'))
+        crit.setOperator('or')
+        # Note: this could have been an ATPortalTypeCriterion too:
+        crit = self.add_criterion(
+            'portal_type',
+            'ATListCriterion', ('Document', 'Folder')
+        )
+        crit.setOperator('and')
+
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        query = self.portal.topic.query
+        self.assertEqual(len(query), 2)
+        self.assertEqual(query[0],
+                         {'i': 'Subject',
+                          'o': 'plone.app.querystring.operation.selection.is',
+                          'v': ('foo', 'bar')})
+        self.assertEqual(query[1],
+                         {'i': 'portal_type',
+                          'o': 'plone.app.querystring.operation.selection.is',
+                          'v': ('Document', 'Folder')})
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATPathCriterion(self):
+        crit = self.add_criterion(
+            'path',
+            'ATPathCriterion', self.portal.folder.UID())
+        crit.setRecurse(True)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        self.assertEqual(self.portal.topic.query,
+                         [{'i': 'path',
+                           'o': 'plone.app.querystring.operation.string.path',
+                           'v': self.portal.folder.UID()}])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATPathCriterionNonRecursive(self):
+        # Topics supported non recursive search, so search at a
+        # specific depth of 1.  At first, new Collections did not
+        # support it.  But since plone.app.querystring 1.1.0 it works.
+        crit = self.add_criterion(
+            'path',
+            'ATPathCriterion', self.portal.folder.UID()
+        )
+        crit.setRecurse(False)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        query = self.portal.topic.query
+        self.assertEqual(len(query), 1)
+        self.assertEqual(query,
+                         [{'i': 'path',
+                           'o': 'plone.app.querystring.operation.string.path',
+                           'v': self.portal.folder.UID() + '::1'}])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATPathCriterionMultiRecursive(self):
+        # Collections support multiple paths since
+        # plone.app.querystring 1.2.0.
+        login(self.portal, 'admin')
+        self.portal.invokeFactory("Folder", "folder2", title="Folder 2")
+        crit = self.add_criterion(
+            'path',
+            'ATPathCriterion',
+            [self.portal.folder.UID(), self.portal.folder2.UID()]
+        )
+        crit.setRecurse(True)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        query = self.portal.topic.query
+        self.assertEqual(len(query), 2)
+        self.assertEqual(query[0],
+                         {'i': 'path',
+                          'o': 'plone.app.querystring.operation.string.path',
+                          'v': self.portal.folder.UID()})
+        self.assertEqual(query[1],
+                         {'i': 'path',
+                          'o': 'plone.app.querystring.operation.string.path',
+                          'v': self.portal.folder2.UID()})
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATPathCriterionMultiNonRecursive(self):
+        # Collections support multiple paths since
+        # plone.app.querystring 1.2.0.
+        login(self.portal, 'admin')
+        self.portal.invokeFactory("Folder", "folder2", title="Folder 2")
+        crit = self.add_criterion(
+            'path',
+            'ATPathCriterion',
+            [self.portal.folder.UID(), self.portal.folder2.UID()]
+        )
+        crit.setRecurse(False)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        query = self.portal.topic.query
+        self.assertEqual(len(query), 2)
+        self.assertEqual(query[0],
+                         {'i': 'path',
+                          'o': 'plone.app.querystring.operation.string.path',
+                          'v': self.portal.folder.UID() + '::1'})
+        self.assertEqual(query[1],
+                         {'i': 'path',
+                          'o': 'plone.app.querystring.operation.string.path',
+                          'v': self.portal.folder2.UID() + '::1'})
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATBooleanCriterion(self):
+        # Note that in standard Plone the boolean criterion is only
+        # defined for is_folderish and is_default_page.
+        crit = self.add_criterion('is_folderish', 'ATBooleanCriterion')
+        crit.setBool(True)
+        crit = self.add_criterion('is_default_page', 'ATBooleanCriterion')
+        crit.setBool(False)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        query = self.portal.topic.query
+        self.assertEqual(len(query), 2)
+        self.assertEqual(
+            query[0],
+            {'i': 'is_folderish',
+             'o': 'plone.app.querystring.operation.boolean.isTrue'}
+        )
+        self.assertEqual(
+            query[1],
+            {'i': 'is_default_page',
+             'o': 'plone.app.querystring.operation.boolean.isFalse'}
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATDateRangeCriteria(self):
+        time1 = DateTime()
+        # Days in the past:
+        crit = self.add_criterion('created', 'ATDateRangeCriterion')
+        crit.setStart(time1 - 5)
+        crit.setEnd(time1 - 3)
+        # Past and future:
+        crit = self.add_criterion('effective', 'ATDateRangeCriterion')
+        crit.setStart(time1 - 2)
+        crit.setEnd(time1 + 2)
+        # Days in the future:
+        crit = self.add_criterion('expires', 'ATDateRangeCriterion')
+        crit.setStart(time1 + 3)
+        crit.setEnd(time1 + 5)
+
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        query = self.portal.topic.query
+        self.assertEqual(len(query), 3)
+
+        self.assertEqual(query[0]['i'], 'created')
+        self.assertEqual(
+            query[0]['o'],
+            'plone.app.querystring.operation.date.between'
+        )
+        self.assertEqual(query[0]['v'], (time1 - 5, time1 - 3))
+
+        self.assertEqual(query[1]['i'], 'effective')
+        self.assertEqual(
+            query[1]['o'],
+            'plone.app.querystring.operation.date.between'
+        )
+        self.assertEqual(query[1]['v'], (time1 - 2, time1 + 2))
+
+        self.assertEqual(query[2]['i'], 'expires')
+        self.assertEqual(
+            query[2]['o'],
+            'plone.app.querystring.operation.date.between'
+        )
+        self.assertEqual(query[2]['v'], (time1 + 3, time1 + 5))
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATPortalTypeCriterion(self):
+        self.add_criterion(
+            'portal_type',
+            'ATPortalTypeCriterion', ('Document', 'Folder')
+        )
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        query = self.portal.topic.query
+        self.assertEqual(
+            query,
+            [{'i': 'portal_type',
+              'o': 'plone.app.querystring.operation.selection.is',
+              'v': ('Document', 'Folder')}]
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATPortalTypeCriterionOfTopic(self):
+        # We migrate Topics to Collections, so we should update
+        # criterions that search for Topics.
+        self.add_criterion(
+            'portal_type',
+            'ATPortalTypeCriterion', ('Topic',)
+        )
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        query = self.portal.topic.query
+        self.assertEqual(query,
+                         [{'i': 'portal_type',
+                           'o': 'plone.app.querystring.operation.selection.is',
+                           'v': ('Collection',)}])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSelectionCriterion(self):
+        # The new-style queries do not currently offer the possibility
+        # to choose if the given values should be joined with 'or' or
+        # 'and'.  Default is 'or'.
+        crit = self.add_criterion(
+            'Subject',
+            'ATSelectionCriterion',
+            ('foo', 'bar')
+        )
+        crit.setOperator('or')
+        # Note: this could have been an ATPortalTypeCriterion too:
+        # Note that we check that Topic is turned into Collection too.
+        crit = self.add_criterion(
+            'portal_type',
+            'ATSelectionCriterion',
+            ('Document', 'Topic')
+        )
+        crit.setOperator('and')
+
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        query = self.portal.topic.query
+        self.assertEqual(len(query), 2)
+        self.assertEqual(query[0],
+                         {'i': 'Subject',
+                          'o': 'plone.app.querystring.operation.selection.is',
+                          'v': ('foo', 'bar')})
+        self.assertEqual(query[1],
+                         {'i': 'portal_type',
+                          'o': 'plone.app.querystring.operation.selection.is',
+                          'v': ('Document', 'Collection')})
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSelectionCriterionForTypeTitle(self):
+        # 'portal_type' is the object id of the FTI in portal_types.
+        # 'Type' is the title of that object.
+        # For example:
+        # - portal_type 'Document' has Type 'Page'.
+        # - portal_type 'Topic' has Type 'Collection (old)'.
+        # Type is not enabled as criterion index by default, so we
+        # want to migrate to a portal_type criterion instead.
+        self.add_criterion('Type', 'ATSelectionCriterion', ('Page', 'Folder'))
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        query = self.portal.topic.query
+        self.assertEqual(query,
+                         [{'i': 'portal_type',
+                           'o': 'plone.app.querystring.operation.selection.is',
+                           'v': ('Document', 'Folder')}])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATReferenceCriterion(self):
+        # Note: the new criterion is disabled by default.  Also, it
+        # needs the _referenceIs function in the plone.app.querystring
+        # queryparser and that function is not defined.
+        self.add_criterion(
+            'getRawRelatedItems',
+            'ATReferenceCriterion',
+            self.portal.folder.UID()
+        )
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        # TODO re-enable this check when the queryparser works.
+        # self.assertEqual(
+        #     self.portal.topic.query,
+        #     [{'i': 'getRawRelatedItems',
+        #       'o': 'plone.app.querystring.operation.reference.is',
+        #       'v': (portal.folder.UID(),)}]
+        # )
+
+        # Check that the resulting query does not give an error.
+        # self.portal.topic.getQuery()
+
+    def test_ATRelativePathCriterion(self):
+        crit = self.add_criterion(
+            'path',
+            'ATRelativePathCriterion'
+        )
+        crit.setRelativePath('../folder')
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        self.assertEqual(
+            self.portal.topic.query,
+            [{'i': 'path',
+              'o': 'plone.app.querystring.operation.string.relativePath',
+              'v': '../folder'}]
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATRelativePathCriterionNonRecursive(self):
+        # Topics supported non recursive search, so search at a specific
+        # depth.  New Collections do not support it.
+        crit = self.add_criterion('path', 'ATRelativePathCriterion')
+        crit.setRelativePath('../folder')
+        crit.setRecurse(True)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        self.assertEqual(
+            self.portal.topic.query,
+            [{'i': 'path',
+              'o': 'plone.app.querystring.operation.string.relativePath',
+              'v': '../folder'}])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSimpleIntCriterion(self):
+        self.add_criterion('getObjPositionInParent', 'ATSimpleIntCriterion', 7)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        self.assertEqual(self.portal.topic.query,
+                         [{'i': 'getObjPositionInParent',
+                           'o': 'plone.app.querystring.operation.int.is',
+                           'v': 7}])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSimpleIntCriterionMinimum(self):
+        crit = self.add_criterion(
+            'getObjPositionInParent',
+            'ATSimpleIntCriterion', 6
+        )
+        crit.setDirection('min')
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        self.assertEqual(
+            self.portal.topic.query,
+            [{'i': 'getObjPositionInParent',
+              'o': 'plone.app.querystring.operation.int.largerThan',
+              'v': 6}]
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSimpleIntCriterionMaximum(self):
+        crit = self.add_criterion(
+            'getObjPositionInParent',
+            'ATSimpleIntCriterion',
+            5
+        )
+        crit.setDirection('max')
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        self.assertEqual(
+            self.portal.topic.query,
+            [{'i': 'getObjPositionInParent',
+              'o': 'plone.app.querystring.operation.int.lessThan',
+              'v': 5}]
+        )
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSimpleIntCriterionBetween(self):
+        # This is not supported.
+        crit = self.add_criterion(
+            'getObjPositionInParent',
+            'ATSimpleIntCriterion',
+            4
+        )
+        crit.setDirection('min:max')
+        crit.setValue2(8)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        self.assertEqual(self.portal.topic.query, [])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSortCriterion(self):
+        self.add_criterion('modified', 'ATSortCriterion')
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        self.assertEqual(self.portal.topic.sort_on, 'modified')
+        self.assertEqual(self.portal.topic.sort_reversed, False)
+        self.assertEqual(self.portal.topic.query, [])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()
+
+    def test_ATSortCriterionReversed(self):
+        crit = self.add_criterion('created', 'ATSortCriterion')
+        crit.setReversed(True)
+        applyProfile(self.portal, 'plone.app.contenttypes:default')
+        self.run_migration()
+        self.assertEqual(self.portal.topic.sort_on, 'created')
+        self.assertEqual(self.portal.topic.sort_reversed, True)
+        self.assertEqual(self.portal.topic.query, [])
+
+        # Check that the resulting query does not give an error.
+        self.portal.topic.getQuery()