Content for different target groups

i'd like to enable editors to define which target group a document is for.
eg. by having a field "Target Group" with a multi-selection of "GroupA, GroupB, GroupC" (not pas groups)

groups are defined by the integrator. usecases/variants:

if a user belongs to a certain target group he shall only be shown group neutral content (no group defined) and group-specific content. (this should include search, navigation, collections)

possible implementation

which groups are available is defined in a vocabulary. the group is set as marker interface on the request in a traverser.
portal-search, navtree, portal-navigation, collections use this marker interface and add an additional search param: targetgroup=A (very similar to linuaplone/plone.app.multilingual that add the language automatically)

did anyone implement something like that before or knows an addon that can be used (as a basis) for that?
i think this would be a very powerful and useful functionality.

1 Like

Hi @frisi - what did you end up doing for this?

in this case we did not implement the functionality similar to linguaplone (as outlined above) but went a cheaper/faster route:

  • enable en_US
  • copy content from en to en_US and add translation references

worked well but - in the long run - will be a PITA for editors to maintain and keep content in sync w/o any additional workflow/tool support.

code to set up support for combined language codes and create language root folder

class EnglischUSAView(BrowserView):

    def __call__(self):
        result = []
        lt = api.portal.get_tool('portal_languages')
        lt.use_combined_language_codes = True
        langs = lt.supported_langs
        idx_en = langs.index('en')
        langs.insert(idx_en + 1, 'en-us')
        lt.supported_langs = langs
        result.append('en-us added as available language')

        setup_view = self.context.restrictedTraverse(
            '@@language-setup-folders')
        result.append(setup_view())
        result.append('\nnow run en/@@copy-content-to to fill it with content')

        return '\n'.join(result)

@@copy-content-to form

# -*- coding: utf-8 -*-
from OFS.CopySupport import CopyError
from Products.Archetypes.Field import ReferenceField
from Products.Archetypes.Field import TextField
from Products.Archetypes.exceptions import ReferenceException
from Products.CMFCore.WorkflowCore import WorkflowException
from Products.statusmessages.interfaces import IStatusMessage
from plone import api
from z3c.form import field, button
from z3c.form.form import Form
from my.pakage import messageFactory as _
from zope import schema
from zope.annotation.interfaces import IAnnotations
from zope.interface import Interface

import logging
import re
import transaction


logger = logging.getLogger(__name__)


class ICopyContentToLanguage(Interface):

    target_languages = schema.List(
        title=_(u'Target languages'),
        description=_(
            u'Select into which languages the translation will be made'),
        value_type=schema.Choice(
            title=_(u'Target languages'),
            vocabulary='plone.app.vocabularies.SupportedContentLanguages'
            ),
        default=[],
        )


class CopyContentToLanguage(Form):
    """create a copy of the context for the chosen language and

    * register translations proplery (set translation references)
    * fix images/links to uids within text to translated content


    based on code erral kindly supplied in this thread:
    https://community.plone.org/t/translating-website-for-different-regions/3792/8?u=frisi
    """

    fields = field.Fields(ICopyContentToLanguage)

    label = _(u'Copy the contents of this objects and its subobjects '
              u'to the selected language/country')
    ignoreContext = True

    @button.buttonAndHandler(u'Copy content')
    def copy_content_to(self, action):

        self.skipped_items = []
        self.problem_items = []

        # {'uid-of-translation': {'fieldname': [object1, object2]}}
        self.references_to_fix = {}

        # dictionary of uids of articles with list of textfields
        # containing resolveuid links
        # {'uid-of-translation': ['text', 'otherfieldname']}
        self.text_contains_uids = {}

        data, errors = self.extractData()
        if errors:
            self.status = self.formErrorsMessage
            return
        target_languages = data.get('target_languages', [])

        path = '/'.join(self.context.getPhysicalPath())
        logger.info('copy content of {} to language(s): {}'.format(
            path,
            ', '.join(target_languages)))

        api.portal.get_tool('portal_catalog')
        brains = list(api.content.find(context=self.context,
                                       sort_on='getObjPositionInParent'))

        # sort brains by length of path to make sure we're not translating
        # an item that has an untranslated parent
        brains.sort(lambda x, y: cmp(len(x.getPath().split('/')),
                                     len(y.getPath().split('/'))))

        count_translated = 0
        for brain in brains:

            obj = brain.getObject()
            if obj != self.context:
                count_translated += self.copy_content_of(obj, target_languages)

        logger.info('translated {} items'.format(count_translated))

        self.fix_references()
        self.fix_resolve_uid_links()

        logger.info('done')
        msg = _(u'Contents copied successfully. Created ${num} translations',
                mapping={'num': count_translated})
        IStatusMessage(self.request).add(msg, type='info')
        if self.skipped_items:
            msg = u'Skipped {} items: {}'.format(
                len(self.skipped_items),
                ', '.join(self.skipped_items))
            IStatusMessage(self.request).add(msg, type='warning')
            logger.warn(msg)
        if self.problem_items:
            logger.warn('problems for {} items:\n {}'.format(
                len(self.problem_items),
                '\n'.join(self.problem_items)))
        return

    def copy_content_of(self, item, target_languages):

        item_path = '/'.join(item.getPhysicalPath())
        if item.portal_type == 'FormFolder':
            logger.warn('Skipping FormFolder ' + item_path)
            self.skipped_items.append(item_path)
            return 0
        if item.aq_parent.portal_type == 'FormFolder':
            logger.info('Skipping FormFolder contents ' + item_path)
            return 0
        count = 0
        logger.info('translating ' + item_path)
        for language in target_languages:
            # create translation
            translation = item.getTranslation(language)

            if translation:
                logger.info('already translated: {}'.format(
                    '/'.join(translation.getPhysicalPath())))
                continue
            try:
                translation = item.addTranslation(language)
            except CopyError, e:
                logger.error(
                    'problem: translation could not be created for ' +
                    item_path)
                logger.error(str(e))
                self.problem_items.append(item_path)
                continue
            count += 1
            translation_path = '/'.join(translation.getPhysicalPath())
            logger.info('created translation for {}: {}'.format(
                language, translation_path))

            self.copy_fields(item, translation)
            self.copy_properties(item, translation)
            self.copy_seo_properties(item, translation)
            self.copy_workflow(item, translation)

            if item.id != translation.id:
                logger.info('correct different id {} and {}'.format(
                    item_path, translation_path))
                try:
                    # Make sure all persistent objects have _p_jar attribute
                    # otherwhise we can get the CopyError
                    # https://docs.plone.org/4/en/develop/plone/content/rename.html
                    transaction.savepoint(optimistic=True)
                    api.content.rename(translation, item.id, True)
                except CopyError:
                    # usually happens if obj.cb_isMoveable()
                    self.problem_items.append(translation_path)
                    logger.warning(
                        'problem: could not rename {} {} to {}'.format(
                            translation.portal_type, translation_path,
                            item.id))

            translation.reindexObject()
        return count

    def copy_fields(self, source, target):
        target_path = '/'.join(target.getPhysicalPath())
        logger.info('copying fields...')
        for field in source.Schema().fields():
            fieldname = field.__name__
            if fieldname.lower() in ['language', 'id']:
                # skip language
                # skip id (setting it makes it unicode which breaks catalogs)
                logger.debug('Skipped %s' % fieldname)
                continue

            target_field = target.getField(fieldname, target)
            if target_field is None:
                logger.warn(
                    ('problem: field {} not available on '
                     'target object {}').format(
                        fieldname, target_path))
                self.problem_items.append(target_path)
                continue
            if target_field.writeable(target):
                value = field.get(source)

                if isinstance(value, unicode):
                    logger.info(u'unicode value! {}: {}'.format(
                        field.getName(), value))
                    value = value.encode('utf-8')
                if value:
                    logger.debug('Set attribute {} in {}'.format(
                        fieldname, target_path))

                    if type(field) == TextField:
                        # no not run transforms on text
                        # keep resolveuid/adfadf/@@images/mini instead of
                        # /path/to/image/@@images/45245345435.jpg
                        value = field.getRaw(source)
                        if 'resolveuid' in value:
                            # remember this object to later change the uids to
                            # the translated objects
                            fieldnames = self.text_contains_uids.get(
                                target.UID(), [])
                            fieldnames.append(fieldname)
                            self.text_contains_uids[target.UID()] = fieldnames

                    if type(field) == ReferenceField:
                        # store references
                        # to fix them later when every content item
                        # has been translated

                        refs = self.references_to_fix.get(target.UID(), {})
                        refs[fieldname] = [ref.UID() for ref in value]
                        self.references_to_fix[target.UID()] = refs
                    try:
                        target_field.set(target, value)
                    except ReferenceException, e:
                        logger.error('invalid references: ' + str(e))

            else:
                logger.info(
                    ('Not writeable. Can not set value for '
                     'field {} in {}.').format(fieldname, target_path))

    def copy_workflow(self, source, target):
        try:
            source_state = api.content.get_state(source)
            current_state = api.content.get_state(target)
        except WorkflowException:
            # no workflow used for this type, skip this step
            return
        if current_state != source_state:
            logger.info('change workflow state to ' + source_state)
            api.content.transition(target, to_state=source_state)

    def copy_seo_properties(self, source, target):
        source_anno = IAnnotations(source)
        keys = [key for key in source_anno.keys() if key.startswith('pSEO_')]
        target_anno = IAnnotations(target)

        for key in keys:
            val = source_anno.get(key)
            target_anno[key] = val
            logger.debug('seo perseo setting {}={}'.format(key, val))

    def copy_properties(self, source, target):
        for prop in source.propertyMap():
            _id = prop['id']
            if _id == 'title':
                continue
            val = source.getProperty(_id)
            target.manage_addProperty(prop['id'], val, prop['type'])
            logger.debug('set property {}: {}'.format(_id, val))

    def fix_references(self):
        """we stored all items that reference others and now we
        fix their references to point to the Translations
        """
        logger.info('fixing reference fields')
        count = 0
        not_translated_count = 0
        for item_uid, refs in self.references_to_fix.iteritems():
            obj = api.content.get(UID=item_uid)
            language = obj.getLanguage()
            path = '/'.join(obj.getPhysicalPath())
            for fieldname, uids in refs.iteritems():
                translated_uids, not_translated = self._translated_uids(
                    uids, language)
                if not_translated:
                    not_translated_count += len(not_translated)
                    logger.warn(
                        ('{}: no {} translations for field {} for these uids: '
                         '{}').format(path, language, fieldname,
                                      ','.join(not_translated)))
                count += len(translated_uids)
                obj.getField(fieldname).set(obj, translated_uids)
            obj.reindexObject()

        logger.info('fixed {} references for {} items '.format(
            count, len(self.references_to_fix)))
        if not_translated_count:
            logger.warn(
                'translation missing for {} items'.format(not_translated_count)
            )

    def _translated_uids(self, uids, language):
        """looks up translations for `uids` for the given `language`
        returns a tuple of two lists (translated_uids, uids_not_translated)
        """
        not_translated = []
        translated_uids = []
        for uid in uids:
            original = api.content.get(UID=uid)
            if original is None:
                # we silently ignore these, as they simply don't appear
                # on the portal and don't lead to errors
                continue
            translation = original.getTranslation(language)
            if translation is None:
                not_translated.append('/'.join(original.getPhysicalPath()))
                continue
            translated_uids.append(translation.UID())
        return (translated_uids, not_translated)

    def fix_resolve_uid_links(self):
        logger.info('fixing resolveuid references in textfields')
        uids = re.compile(r'(?<=resolveuid/)[0-9,a-f]*', re.DOTALL)
        count_articles = 0
        # trick to overcome UnboundLocalError
        # http://stackoverflow.com/a/9264811/810427
        count_broken = [0]
        count_not_translated = [0]
        count_replaced = [0]
        for item_uid, fieldnames in self.text_contains_uids.iteritems():
            obj = api.content.get(UID=item_uid)
            language = obj.getLanguage()
            path = '/'.join(obj.getPhysicalPath())

            for fieldname in fieldnames:
                field = obj.getField(fieldname)
                text = field.getRaw(obj)

                def replaceuid(m):
                    uid = m.group(0)
                    obj = api.content.get(UID=uid)
                    if obj is None:
                        logger.warn(
                            'broken link for {} field {} uid {}'.format(
                                path, fieldname, uid))
                        count_broken[0] += 1
                        return uid
                    translation = obj.getTranslation(language)
                    if translation is None:
                        logger.warn(
                            ('{}: field "{}" no translation for '
                             'uid {} ({})').format(
                                path, fieldname, uid,
                                '/'.join(obj.getPhysicalPath())))
                        count_not_translated[0] += 1
                        return uid
                    count_replaced[0] += 1
                    return translation.UID()

                new_text = re.sub(uids, replaceuid, text)
                field.set(obj, new_text)
            count_articles += 1
            obj.reindexObject()

        logger.info('fixed {} resolveuid references for {} items '.format(
            count_replaced[0], count_articles))
        if count_broken[0]:
            logger.warn(
                'found {} broken links (see log above)'.format(
                    count_broken[0]))
        if count_not_translated[0]:
            logger.warn(
                '{} objects have no translation (see log above'.format(
                    count_not_translated[0]))

1 Like