Beispiel #1
0
class FormQuestionSchema(Document):
    """
    Contains information about the questions for a specific form
    specifically the options that are available (or have ever been available) for
    any multi-select questions.
    Calling `update_schema` will load the app and any saved versions of the app
    that have not already been processed and update the question schema with
    any new options.
    """
    domain = StringProperty(required=True)
    app_id = StringProperty(required=True)
    xmlns = StringProperty(required=True)

    last_processed_version = IntegerProperty(default=0)
    processed_apps = SetProperty(str)
    apps_with_errors = SetProperty(str)
    question_schema = SchemaDictProperty(QuestionMeta)

    class Meta(object):
        app_label = 'export'

    @classmethod
    def _get_id(cls, domain, app_id, xmlns):
        def _none_to_empty_string(str):
            return str if str is not None else ''

        key = list(map(_none_to_empty_string, [domain, app_id, xmlns]))
        return hashlib.sha1(':'.join(key).encode('utf-8')).hexdigest()

    @classmethod
    def get_by_key(cls, domain, app_id, xmlns):
        _id = cls._get_id(domain, app_id, xmlns)
        return cls.get(_id)

    @classmethod
    def get_or_create(cls, domain, app_id, xmlns):
        try:
            schema = cls.get_by_key(domain, app_id, xmlns)
        except ResourceNotFound:
            old_schemas = FormQuestionSchema.view(
                'form_question_schema/by_xmlns',
                key=[domain, app_id, xmlns],
                include_docs=True).all()

            if old_schemas:
                doc = old_schemas[0].to_json()
                del doc['_id']
                del doc['_rev']
                schema = FormQuestionSchema.wrap(doc)
                schema.save()

                for old in old_schemas:
                    old.delete()
            else:
                schema = FormQuestionSchema(domain=domain,
                                            app_id=app_id,
                                            xmlns=xmlns)
                schema.save()

        return schema

    def validate(self, required=True):
        # this isn't always set, so set to empty strings if not found
        if self.app_id is None:
            self.app_id = ''

        super(FormQuestionSchema, self).validate(required=required)
        if not self.get_id:
            self._id = self._get_id(self.domain, self.app_id, self.xmlns)

    def update_schema(self):
        all_app_ids = get_build_ids_after_version(self.domain, self.app_id,
                                                  self.last_processed_version)

        all_seen_apps = self.apps_with_errors | self.processed_apps
        to_process = [
            app_id for app_id in all_app_ids if app_id not in all_seen_apps
        ]
        if self.app_id not in all_seen_apps:
            to_process.append(self.app_id)

        for app_doc in iter_docs(Application.get_db(), to_process):
            if is_remote_app(app_doc):
                continue
            app = Application.wrap(app_doc)
            try:
                self.update_for_app(app)
            except AppManagerException:
                self.apps_with_errors.add(app.get_id)
                self.last_processed_version = app.version

        if to_process:
            self.save()

    def update_for_app(self, app):
        xform = app.get_xform_by_xmlns(self.xmlns, log_missing=False)
        if xform:
            prefix = '/{}/'.format(xform.data_node.tag_name)

            def to_json_path(xml_path):
                if not xml_path:
                    return

                if xml_path.startswith(prefix):
                    xml_path = xml_path[len(prefix):]
                return 'form.{}'.format(xml_path.replace('/', '.'))

            for question in xform.get_questions(app.langs):
                question_path = to_json_path(question['value'])
                if question['tag'] == 'select':
                    meta = self.question_schema.get(
                        question_path,
                        QuestionMeta(
                            repeat_context=to_json_path(question['repeat'])))
                    for opt in question['options']:
                        if opt['value'] not in meta.options:
                            meta.options.append(opt['value'])

                    self.question_schema[question_path] = meta
                else:
                    # In the event that a question was previously a multi-select and not one any longer,
                    # we need to clear the question schema
                    self.question_schema.pop(question_path, None)

        self.processed_apps.add(app.get_id)
        self.last_processed_version = app.version
Beispiel #2
0
class SimplifiedSyncLog(AbstractSyncLog):
    """
    New, simplified sync log class that is used by ownership cleanliness restore.

    Just maintains a flat list of case IDs on the phone rather than the case/dependent state
    lists from the SyncLog class.
    """
    log_format = StringProperty(default=LOG_FORMAT_SIMPLIFIED)
    case_ids_on_phone = SetProperty(six.text_type)
    # this is a subset of case_ids_on_phone used to flag that a case is only around because it has dependencies
    # this allows us to purge it if possible from other actions
    dependent_case_ids_on_phone = SetProperty(six.text_type)
    owner_ids_on_phone = SetProperty(six.text_type)
    index_tree = SchemaProperty(IndexTree)  # index tree of subcases / children
    extension_index_tree = SchemaProperty(
        IndexTree)  # index tree of extensions
    closed_cases = SetProperty(six.text_type)
    extensions_checked = BooleanProperty(default=False)
    device_id = StringProperty()

    _purged_cases = None

    @property
    def purged_cases(self):
        if self._purged_cases is None:
            self._purged_cases = set()
        return self._purged_cases

    def case_count(self):
        return len(self.case_ids_on_phone)

    def phone_is_holding_case(self, case_id):
        """
        Whether the phone currently has a case, according to this sync log
        """
        return case_id in self.case_ids_on_phone

    def get_footprint_of_cases_on_phone(self):
        return list(self.case_ids_on_phone)

    @property
    def primary_case_ids(self):
        return self.case_ids_on_phone - self.dependent_case_ids_on_phone

    def purge(self, case_id, xform_id=None):
        """
        This happens in 3 phases, and recursively tries to purge outgoing indices of purged cases.
        Definitions:
        -----------
        A case is *relevant* if:
        - it is open and owned or,
        - it has a relevant child or,
        - it has a relevant extension or,
        - it is the extension of a relevant case.

        A case is *available* if:
        - it is open and not an extension case or,
        - it is open and is the extension of an available case.

        A case is *live* if:
        - it is owned and available or,
        - it has a live child or,
        - it has a live extension or,
        - it is the exension of a live case.

        Algorithm:
        ----------
        1. Mark *relevant* cases
            Mark all open cases owned by the user relevant. Traversing all outgoing child
            and extension indexes, as well as all incoming extension indexes, mark all
            touched cases relevant.

        2. Mark *available* cases
            Mark all relevant cases that are open and have no outgoing extension indexes
            as available. Traverse incoming extension indexes which don't lead to closed
            cases, mark all touched cases as available.

        3. Mark *live* cases
            Mark all relevant, owned, available cases as live. Traverse incoming
            extension indexes which don't lead to closed cases, mark all touched
            cases as live.
        """
        _get_logger().debug("purging: {}".format(case_id))
        self.dependent_case_ids_on_phone.add(case_id)
        relevant = self._get_relevant_cases(case_id)
        available = self._get_available_cases(relevant)
        live = self._get_live_cases(available)
        to_remove = (relevant - self.purged_cases) - live
        self._remove_cases_purge_indices(to_remove, case_id, xform_id)

    def _get_relevant_cases(self, case_id):
        """
        Mark all open cases owned by the user relevant. Traversing all outgoing child
        and extension indexes, as well as all incoming extension indexes,
        mark all touched cases relevant.
        """
        relevant = IndexTree.get_all_dependencies(
            case_id,
            child_index_tree=self.index_tree,
            extension_index_tree=self.extension_index_tree,
        )
        _get_logger().debug("Relevant cases of {}: {}".format(
            case_id, relevant))
        return relevant

    def _get_available_cases(self, relevant):
        """
        Mark all relevant cases that are open and have no outgoing extension indexes
        as available. Traverse incoming extension indexes which don't lead to closed
        cases, mark all touched cases as available
        """
        incoming_extensions = self.extension_index_tree.reverse_indices
        available = {
            case
            for case in relevant if case not in self.closed_cases and (
                not self.extension_index_tree.indices.get(case)
                or self.index_tree.indices.get(case))
        }
        new_available = set() | available
        while new_available:
            case_to_check = new_available.pop()
            for incoming_extension in incoming_extensions.get(
                    case_to_check, []):
                closed = incoming_extension in self.closed_cases
                purged = incoming_extension in self.purged_cases
                if not closed and not purged:
                    new_available.add(incoming_extension)
            available = available | new_available
        _get_logger().debug("Available cases: {}".format(available))

        return available

    def _get_live_cases(self, available):
        """
        Mark all relevant, owned, available cases as live. Traverse incoming
        extension indexes which don't lead to closed cases, mark all touched
        cases as available.
        """
        primary_case_ids = self.primary_case_ids
        live = available & primary_case_ids
        new_live = set() | live
        checked = set()
        while new_live:
            case_to_check = new_live.pop()
            checked.add(case_to_check)
            new_live = new_live | IndexTree.get_all_outgoing_cases(
                case_to_check, self.index_tree,
                self.extension_index_tree) - self.purged_cases
            new_live = new_live | IndexTree.traverse_incoming_extensions(
                case_to_check,
                self.extension_index_tree,
                frozenset(self.closed_cases),
            ) - self.purged_cases
            new_live = new_live - checked
            live = live | new_live

        _get_logger().debug("live cases: {}".format(live))

        return live

    def _remove_cases_purge_indices(self, all_to_remove, checked_case_id,
                                    xform_id):
        """Remove all cases marked for removal. Traverse child cases and try to purge those too."""

        _get_logger().debug("cases to to_remove: {}".format(all_to_remove))
        for to_remove in all_to_remove:
            indices = self.index_tree.indices.get(to_remove, {})
            self._remove_case(to_remove, all_to_remove, checked_case_id,
                              xform_id)
            for referenced_case in indices.values():
                is_dependent_case = referenced_case in self.dependent_case_ids_on_phone
                already_primed_for_removal = referenced_case in all_to_remove
                if is_dependent_case and not already_primed_for_removal and referenced_case != checked_case_id:
                    self.purge(referenced_case, xform_id)

    def _remove_case(self, to_remove, all_to_remove, checked_case_id,
                     xform_id):
        """Removes case from index trees, case_ids_on_phone and dependent_case_ids_on_phone if pertinent"""
        _get_logger().debug('removing: {}'.format(to_remove))

        deleted_indices = self.index_tree.indices.pop(to_remove, {})
        deleted_indices.update(
            self.extension_index_tree.indices.pop(to_remove, {}))

        self._validate_case_removal(to_remove, all_to_remove, deleted_indices,
                                    checked_case_id, xform_id)

        try:
            self.case_ids_on_phone.remove(to_remove)
        except KeyError:
            should_fail_softly = not xform_id or _domain_has_legacy_toggle_set(
            )
            if should_fail_softly:
                pass
            else:
                # this is only a soft assert for now because of http://manage.dimagi.com/default.asp?181443
                # we should convert back to a real Exception when we stop getting any of these
                _assert = soft_assert(notify_admins=True,
                                      exponential_backoff=False)
                _assert(
                    False, 'case already remove from synclog', {
                        'case_id': to_remove,
                        'synclog_id': self._id,
                        'form_id': xform_id
                    })
        else:
            self.purged_cases.add(to_remove)

        if to_remove in self.dependent_case_ids_on_phone:
            self.dependent_case_ids_on_phone.remove(to_remove)

    def _validate_case_removal(self, case_to_remove, all_to_remove,
                               deleted_indices, checked_case_id, xform_id):
        """Traverse immediate outgoing indices. Validate that these are also candidates for removal."""
        if case_to_remove == checked_case_id:
            return

        # Logging removed temporarily: https://github.com/dimagi/commcare-hq/pull/16259#issuecomment-303176217
        # for index in deleted_indices.values():
        #     if xform_id and not _domain_has_legacy_toggle_set():
        #         # unblocking http://manage.dimagi.com/default.asp?185850
        #         _assert = soft_assert(send_to_ops=False, log_to_file=True, exponential_backoff=True,
        #                               fail_if_debug=True)
        #         _assert(index in (all_to_remove | set([checked_case_id])),
        #                 "expected {} in {} but wasn't".format(index, all_to_remove))

    def _add_primary_case(self, case_id):
        self.case_ids_on_phone.add(case_id)
        if case_id in self.dependent_case_ids_on_phone:
            self.dependent_case_ids_on_phone.remove(case_id)

    def _add_index(self, index, case_update):
        _get_logger().debug('adding index {} --<{}>--> {} ({}).'.format(
            index.case_id, index.relationship, index.referenced_id,
            index.identifier))
        if index.relationship == const.CASE_INDEX_EXTENSION:
            self._add_extension_index(index, case_update)
        else:
            self._add_child_index(index)

    def _add_extension_index(self, index, case_update):
        assert index.relationship == const.CASE_INDEX_EXTENSION
        self.extension_index_tree.set_index(index.case_id, index.identifier,
                                            index.referenced_id)

        if index.referenced_id not in self.case_ids_on_phone:
            self.case_ids_on_phone.add(index.referenced_id)
            self.dependent_case_ids_on_phone.add(index.referenced_id)

        case_child_indices = [
            idx for idx in case_update.indices_to_add
            if idx.relationship == const.CASE_INDEX_CHILD
            and idx.referenced_id == index.referenced_id
        ]
        if not case_child_indices and not case_update.is_live:
            # this case doesn't also have child indices, and it is not owned, so it is dependent
            self.dependent_case_ids_on_phone.add(index.case_id)

    def _add_child_index(self, index):
        assert index.relationship == const.CASE_INDEX_CHILD
        self.index_tree.set_index(index.case_id, index.identifier,
                                  index.referenced_id)
        if index.referenced_id not in self.case_ids_on_phone:
            self.case_ids_on_phone.add(index.referenced_id)
            self.dependent_case_ids_on_phone.add(index.referenced_id)

    def _delete_index(self, index):
        self.index_tree.delete_index(index.case_id, index.identifier)
        self.extension_index_tree.delete_index(index.case_id, index.identifier)

    def update_phone_lists(self, xform, case_list):
        made_changes = False
        _get_logger().debug('updating sync log for {}'.format(self.user_id))
        _get_logger().debug('case ids before update: {}'.format(', '.join(
            self.case_ids_on_phone)))
        _get_logger().debug('dependent case ids before update: {}'.format(
            ', '.join(self.dependent_case_ids_on_phone)))
        _get_logger().debug('index tree before update: {}'.format(
            self.index_tree))
        _get_logger().debug('extension index tree before update: {}'.format(
            self.extension_index_tree))

        class CaseUpdate(object):
            def __init__(self, case_id, owner_ids_on_phone):
                self.case_id = case_id
                self.owner_ids_on_phone = owner_ids_on_phone
                self.was_live_previously = True
                self.final_owner_id = None
                self.is_closed = None
                self.indices_to_add = []
                self.indices_to_delete = []

            @property
            def extension_indices_to_add(self):
                return [
                    index for index in self.indices_to_add
                    if index.relationship == const.CASE_INDEX_EXTENSION
                ]

            def has_extension_indices_to_add(self):
                return len(self.extension_indices_to_add) > 0

            @property
            def is_live(self):
                """returns whether an update is live for a specifc set of owner_ids"""
                if self.is_closed:
                    return False
                elif self.final_owner_id is None:
                    # we likely didn't touch owner_id so just default to whatever it was previously
                    return self.was_live_previously
                else:
                    return self.final_owner_id in self.owner_ids_on_phone

        ShortIndex = namedtuple(
            'ShortIndex',
            ['case_id', 'identifier', 'referenced_id', 'relationship'])

        # this is a variable used via closures in the function below
        owner_id_map = {}

        def get_latest_owner_id(case_id, action=None):
            # "latest" just means as this forms actions are played through
            if action is not None:
                owner_id_from_action = action.updated_known_properties.get(
                    "owner_id")
                if owner_id_from_action is not None:
                    owner_id_map[case_id] = owner_id_from_action
            return owner_id_map.get(case_id, None)

        all_updates = {}
        for case in case_list:
            if case.case_id not in all_updates:
                _get_logger().debug('initializing update for case {}'.format(
                    case.case_id))
                all_updates[case.case_id] = CaseUpdate(
                    case_id=case.case_id,
                    owner_ids_on_phone=self.owner_ids_on_phone)

            case_update = all_updates[case.case_id]
            case_update.was_live_previously = case.case_id in self.primary_case_ids
            actions = case.get_actions_for_form(xform)
            for action in actions:
                _get_logger().debug('{}: {}'.format(case.case_id,
                                                    action.action_type))
                owner_id = get_latest_owner_id(case.case_id, action)
                if owner_id is not None:
                    case_update.final_owner_id = owner_id
                if action.action_type == const.CASE_ACTION_INDEX:
                    for index in action.indices:
                        if index.referenced_id:
                            case_update.indices_to_add.append(
                                ShortIndex(case.case_id, index.identifier,
                                           index.referenced_id,
                                           index.relationship))
                        else:
                            case_update.indices_to_delete.append(
                                ShortIndex(case.case_id, index.identifier,
                                           None, None))
                elif action.action_type == const.CASE_ACTION_CLOSE:
                    case_update.is_closed = True

        non_live_updates = []
        for case in case_list:
            case_update = all_updates[case.case_id]
            if case_update.is_live:
                _get_logger().debug('case {} is live.'.format(
                    case_update.case_id))
                if case.case_id not in self.case_ids_on_phone:
                    self._add_primary_case(case.case_id)
                    made_changes = True
                elif case.case_id in self.dependent_case_ids_on_phone:
                    self.dependent_case_ids_on_phone.remove(case.case_id)
                    made_changes = True

                for index in case_update.indices_to_add:
                    self._add_index(index, case_update)
                    made_changes = True
                for index in case_update.indices_to_delete:
                    self._delete_index(index)
                    made_changes = True
Beispiel #3
0
class Group(QuickCachedDocumentMixin, UndoableDocument):
    """
    The main use case for these 'groups' of users is currently
    so that we can break down reports by arbitrary regions.

    (Things like who sees what reports are determined by permissions.)
    """
    domain = StringProperty()
    name = StringProperty()
    # a list of user ids for users
    users = ListProperty()
    # a list of user ids that have been removed from the Group.
    # This is recorded so that we can update the user at a later point
    removed_users = SetProperty()
    path = ListProperty()
    case_sharing = BooleanProperty()
    reporting = BooleanProperty(default=True)
    last_modified = DateTimeProperty()

    # custom data can live here
    metadata = DictProperty()

    @classmethod
    def wrap(cls, data):
        last_modified = data.get('last_modified')
        # if it's missing a Z because of the Aug. 2014 migration
        # that added this in iso_format() without Z, then add a Z
        if last_modified and dt_no_Z_re.match(last_modified):
            data['last_modified'] += 'Z'
        return super(Group, cls).wrap(data)

    def save(self, *args, **kwargs):
        self.last_modified = datetime.utcnow()
        super(Group, self).save(*args, **kwargs)
        refresh_group_views()

    @classmethod
    def save_docs(cls, docs, use_uuids=True):
        utcnow = datetime.utcnow()
        for doc in docs:
            doc['last_modified'] = utcnow
        super(Group, cls).save_docs(docs, use_uuids)
        refresh_group_views()

    bulk_save = save_docs

    def delete(self):
        super(Group, self).delete()
        refresh_group_views()

    @classmethod
    def delete_docs(cls, docs, **params):
        super(Group, cls).delete_docs(docs, **params)
        refresh_group_views()

    bulk_delete = delete_docs

    def clear_caches(self):
        super(Group, self).clear_caches()
        self.by_domain.clear(self.__class__, self.domain)
        self.ids_by_domain.clear(self.__class__, self.domain)

    def add_user(self, couch_user_id, save=True):
        if not isinstance(couch_user_id, str):
            couch_user_id = couch_user_id.user_id
        if couch_user_id not in self.users:
            self.users.append(couch_user_id)
        if couch_user_id in self.removed_users:
            self.removed_users.remove(couch_user_id)
        if save:
            self.save()

    def remove_user(self, couch_user_id):
        '''
        Returns True if it removed a user, False otherwise
        '''
        if not isinstance(couch_user_id, str):
            couch_user_id = couch_user_id.user_id
        if couch_user_id in self.users:
            for i in range(0, len(self.users)):
                if self.users[i] == couch_user_id:
                    del self.users[i]
                    self.removed_users.add(couch_user_id)
                    return True
        return False

    def add_group(self, group):
        group.add_to_group(self)

    def add_to_group(self, group):
        """
        food = Food(path=[food_id])
        fruit = Fruit(path=[fruit_id])

        If fruit.add_to_group(food._id):
            then update fruit.path to be [food_id, fruit_id]
        """
        group_id = group._id
        if group_id in self.path:
            raise Exception("Group %s is already a member of %s" % (
                self.get_id,
                group_id,
            ))
        new_path = [group_id]
        new_path.extend(self.path)
        self.path = new_path
        self.save()

    def remove_group(self, group):
        group.remove_from_group(self)

    def remove_from_group(self, group):
        """
        food = Food(path=[food_id])
        fruit = Fruit(path=[food_id, fruit_id])

        If fruit.remove_from_group(food._id):
            then update fruit.path to be [fruit_id]
        """
        group_id = group._id
        if group_id not in self.path:
            raise Exception("Group %s is not a member of %s" % (
                self.get_id,
                group_id
            ))
        index = 0
        for i in range(0, len(self.path)):
            if self.path[i] == group_id:
                index = i
                break
        self.path = self.path[index:]
        self.save()

    def get_user_ids(self, is_active=True):
        return [user.user_id for user in self.get_users(is_active=is_active)]

    @memoized
    def get_users(self, is_active=True, only_commcare=False):
        def is_relevant_user(user):
            if user.is_deleted():
                return False
            if only_commcare and user.__class__ != CommCareUser().__class__:
                return False
            if is_active and not user.is_active:
                return False
            return True
        users = map(CouchUser.wrap_correctly, iter_docs(self.get_db(), self.users))
        return list(filter(is_relevant_user, users))

    @memoized
    def get_static_user_ids(self, is_active=True):
        return [user.user_id for user in self.get_static_users(is_active)]

    @classmethod
    def get_static_user_ids_for_groups(cls, group_ids):
        static_user_ids = []
        for group_id in group_ids:
            group = cls.get(group_id)
            static_user_ids.append(group.get_static_user_ids())
        return static_user_ids

    @memoized
    def get_static_users(self, is_active=True):
        return self.get_users(is_active)

    @classmethod
    @quickcache(['cls.__name__', 'domain'])
    def by_domain(cls, domain):
        return group_by_domain(domain)

    @classmethod
    def choices_by_domain(cls, domain):
        group_ids = cls.ids_by_domain(domain)
        group_choices = []
        for group_doc in iter_docs(cls.get_db(), group_ids):
            group_choices.append((group_doc['_id'], group_doc['name']))
        return group_choices

    @classmethod
    @quickcache(['cls.__name__', 'domain'])
    def ids_by_domain(cls, domain):
        return get_group_ids_by_domain(domain)

    @classmethod
    def by_name(cls, domain, name, one=True):
        result = stale_group_by_name(domain, name)
        if one and result:
            return result[0]
        else:
            return result

    @classmethod
    def by_user_id(cls, user_id, wrap=True):
        results = cls.view('groups/by_user', key=user_id, include_docs=wrap)
        if wrap:
            return results
        else:
            return [r['id'] for r in results]

    @classmethod
    def get_case_sharing_accessible_locations(cls, domain, user):
        return [
            location.case_sharing_group_object() for location in
            SQLLocation.objects.accessible_to_user(domain, user).filter(location_type__shares_cases=True)
        ]

    @classmethod
    def get_case_sharing_groups(cls, domain, wrap=True):
        all_groups = cls.by_domain(domain)
        if wrap:
            groups = [group for group in all_groups if group.case_sharing]
            groups.extend([
                location.case_sharing_group_object() for location in
                SQLLocation.objects.filter(domain=domain,
                                           location_type__shares_cases=True)
            ])
            return groups
        else:
            return [group._id for group in all_groups if group.case_sharing]

    @classmethod
    def get_reporting_groups(cls, domain):
        key = ['^Reporting', domain]
        return cls.view(
            'groups/by_name',
            startkey=key,
            endkey=key + [{}],
            include_docs=True,
            stale=settings.COUCH_STALE_QUERY,
        ).all()

    def create_delete_record(self, *args, **kwargs):
        return DeleteGroupRecord(*args, **kwargs)

    @property
    def display_name(self):
        if self.name:
            return self.name
        else:
            return "[No Name]"

    @classmethod
    def user_in_group(cls, user_id, group_id):
        if not user_id or not group_id:
            return False
        c = cls.get_db().view(
            'groups/by_user',
            key=user_id,
            startkey_docid=group_id,
            endkey_docid=group_id
        ).count()
        if c == 0:
            return False
        elif c == 1:
            return True
        else:
            raise Exception(
                "This should just logically not be possible unless the group "
                "has the user in there twice"
            )

    def is_member_of(self, domain):
        return self.domain == domain

    @property
    def is_deleted(self):
        return self.doc_type.endswith(DELETED_SUFFIX)

    def __repr__(self):
        return ("Group(domain={self.domain!r}, name={self.name!r}, "
                "case_sharing={self.case_sharing!r})").format(self=self)