diff -r 058bb3dc685f -r 0b59724cb3f2 cubicweb/predicates.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cubicweb/predicates.py Sat Jan 16 13:48:51 2016 +0100 @@ -0,0 +1,1421 @@ +# copyright 2003-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved. +# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr +# +# This file is part of CubicWeb. +# +# CubicWeb is free software: you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 2.1 of the License, or (at your option) +# any later version. +# +# CubicWeb is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with CubicWeb. If not, see . +"""Predicate classes +""" + +__docformat__ = "restructuredtext en" + +import logging +from warnings import warn +from operator import eq + +from six import string_types, integer_types +from six.moves import range + +from logilab.common.deprecation import deprecated +from logilab.common.registry import Predicate, objectify_predicate, yes + +from yams.schema import BASE_TYPES, role_name +from rql.nodes import Function + +from cubicweb import (Unauthorized, NoSelectableObject, NotAnEntity, + CW_EVENT_MANAGER, role) +from cubicweb.uilib import eid_param +from cubicweb.schema import split_expression + +yes = deprecated('[3.15] import yes() from use logilab.common.registry')(yes) + + +# abstract predicates / mixin helpers ########################################### + +class PartialPredicateMixIn(object): + """convenience mix-in for predicates that will look into the containing + class to find missing information. + + cf. `cubicweb.web.action.LinkToEntityAction` for instance + """ + def __call__(self, cls, *args, **kwargs): + self.complete(cls) + return super(PartialPredicateMixIn, self).__call__(cls, *args, **kwargs) + + +class EClassPredicate(Predicate): + """abstract class for predicates working on *entity class(es)* specified + explicitly or found of the result set. + + Here are entity lookup / scoring rules: + + * if `entity` is specified, return score for this entity's class + + * elif `rset`, `select` and `filtered_variable` are specified, return score + for the possible classes for variable in the given rql :class:`Select` + node + + * elif `rset` and `row` are specified, return score for the class of the + entity found in the specified cell, using column specified by `col` or 0 + + * elif `rset` is specified return score for each entity class found in the + column specified specified by the `col` argument or in column 0 if not + specified + + When there are several classes to be evaluated, return the sum of scores for + each entity class unless: + + - `mode` == 'all' (the default) and some entity class is scored + to 0, in which case 0 is returned + + - `mode` == 'any', in which case the first non-zero score is + returned + + - `accept_none` is False and some cell in the column has a None value + (this may occurs with outer join) + """ + def __init__(self, once_is_enough=None, accept_none=True, mode='all'): + if once_is_enough is not None: + warn("[3.14] once_is_enough is deprecated, use mode='any'", + DeprecationWarning, stacklevel=2) + if once_is_enough: + mode = 'any' + assert mode in ('any', 'all'), 'bad mode %s' % mode + self.once_is_enough = mode == 'any' + self.accept_none = accept_none + + def __call__(self, cls, req, rset=None, row=None, col=0, entity=None, + select=None, filtered_variable=None, + accept_none=None, + **kwargs): + if entity is not None: + return self.score_class(entity.__class__, req) + if not rset: + return 0 + if select is not None and filtered_variable is not None: + etypes = set(sol[filtered_variable.name] for sol in select.solutions) + elif row is None: + if accept_none is None: + accept_none = self.accept_none + if not accept_none and \ + any(row[col] is None for row in rset): + return 0 + etypes = rset.column_types(col) + else: + etype = rset.description[row][col] + # may have None in rset.description on outer join + if etype is None or rset.rows[row][col] is None: + return 0 + etypes = (etype,) + score = 0 + for etype in etypes: + escore = self.score(cls, req, etype) + if not escore and not self.once_is_enough: + return 0 + elif self.once_is_enough: + return escore + score += escore + return score + + def score(self, cls, req, etype): + if etype in BASE_TYPES: + return 0 + return self.score_class(req.vreg['etypes'].etype_class(etype), req) + + def score_class(self, eclass, req): + raise NotImplementedError() + + +class EntityPredicate(EClassPredicate): + """abstract class for predicates working on *entity instance(s)* specified + explicitly or found of the result set. + + Here are entity lookup / scoring rules: + + * if `entity` is specified, return score for this entity + + * elif `row` is specified, return score for the entity found in the + specified cell, using column specified by `col` or 0 + + * else return the sum of scores for each entity found in the column + specified specified by the `col` argument or in column 0 if not specified, + unless: + + - `mode` == 'all' (the default) and some entity class is scored + to 0, in which case 0 is returned + + - `mode` == 'any', in which case the first non-zero score is + returned + + - `accept_none` is False and some cell in the column has a None value + (this may occurs with outer join) + + .. Note:: + using :class:`EntityPredicate` or :class:`EClassPredicate` as base predicate + class impacts performance, since when no entity or row is specified the + later works on every different *entity class* found in the result set, + while the former works on each *entity* (eg each row of the result set), + which may be much more costly. + """ + + def __call__(self, cls, req, rset=None, row=None, col=0, accept_none=None, + entity=None, **kwargs): + if not rset and entity is None: + return 0 + score = 0 + if entity is not None: + score = self.score_entity(entity) + elif row is None: + col = col or 0 + if accept_none is None: + accept_none = self.accept_none + for row, rowvalue in enumerate(rset.rows): + if rowvalue[col] is None: # outer join + if not accept_none: + return 0 + continue + escore = self.score(req, rset, row, col) + if not escore and not self.once_is_enough: + return 0 + elif self.once_is_enough: + return escore + score += escore + else: + col = col or 0 + etype = rset.description[row][col] + if etype is not None: # outer join + score = self.score(req, rset, row, col) + return score + + def score(self, req, rset, row, col): + try: + return self.score_entity(rset.get_entity(row, col)) + except NotAnEntity: + return 0 + + def score_entity(self, entity): + raise NotImplementedError() + + +class ExpectedValuePredicate(Predicate): + """Take a list of expected values as initializer argument and store them + into the :attr:`expected` set attribute. You may also give a set as single + argument, which will then be referenced as set of expected values, + allowing modifications to the given set to be considered. + + You should implement one of :meth:`_values_set(cls, req, **kwargs)` or + :meth:`_get_value(cls, req, **kwargs)` method which should respectively + return the set of values or the unique possible value for the given context. + + You may also specify a `mode` behaviour as argument, as explained below. + + Returned score is: + + - 0 if `mode` == 'all' (the default) and at least one expected + values isn't found + + - 0 if `mode` == 'any' and no expected values isn't found at all + + - else the number of matching values + + Notice `mode` = 'any' with a single expected value has no effect at all. + """ + def __init__(self, *expected, **kwargs): + assert expected, self + if len(expected) == 1 and isinstance(expected[0], (set, dict)): + self.expected = expected[0] + else: + self.expected = frozenset(expected) + mode = kwargs.pop('mode', 'all') + assert mode in ('any', 'all'), 'bad mode %s' % mode + self.once_is_enough = mode == 'any' + assert not kwargs, 'unexpected arguments %s' % kwargs + + def __str__(self): + return '%s(%s)' % (self.__class__.__name__, + ','.join(sorted(str(s) for s in self.expected))) + + def __call__(self, cls, req, **kwargs): + values = self._values_set(cls, req, **kwargs) + if isinstance(values, dict): + if isinstance(self.expected, dict): + matching = 0 + for key, expected_value in self.expected.items(): + if key in values: + if (isinstance(expected_value, (list, tuple, frozenset, set)) + and values[key] in expected_value): + matching += 1 + elif values[key] == expected_value: + matching += 1 + if isinstance(self.expected, (set, frozenset)): + values = frozenset(values) + matching = len(values & self.expected) + else: + matching = len(values & self.expected) + if self.once_is_enough: + return matching + if matching == len(self.expected): + return matching + return 0 + + def _values_set(self, cls, req, **kwargs): + return frozenset( (self._get_value(cls, req, **kwargs),) ) + + def _get_value(self, cls, req, **kwargs): + raise NotImplementedError() + + +# bare predicates ############################################################## + +class match_kwargs(ExpectedValuePredicate): + """Return non-zero score if parameter names specified as initializer + arguments are specified in the input context. + + + Return a score corresponding to the number of expected parameters. + + When multiple parameters are expected, all of them should be found in + the input context unless `mode` keyword argument is given to 'any', + in which case a single matching parameter is enough. + """ + + def _values_set(self, cls, req, **kwargs): + return kwargs + + +class appobject_selectable(Predicate): + """Return 1 if another appobject is selectable using the same input context. + + Initializer arguments: + + * `registry`, a registry name + + * `regids`, object identifiers in this registry, one of them should be + selectable. + """ + selectable_score = 1 + def __init__(self, registry, *regids): + self.registry = registry + self.regids = regids + + def __call__(self, cls, req, **kwargs): + for regid in self.regids: + if req.vreg[self.registry].select_or_none(regid, req, **kwargs) is not None: + return self.selectable_score + return 0 + + +class adaptable(appobject_selectable): + """Return 1 if another appobject is selectable using the same input context. + + Initializer arguments: + + * `regids`, adapter identifiers (e.g. interface names) to which the context + (usually entities) should be adaptable. One of them should be selectable + when multiple identifiers are given. + """ + def __init__(self, *regids): + super(adaptable, self).__init__('adapters', *regids) + + def __call__(self, cls, req, **kwargs): + kwargs.setdefault('accept_none', False) + score = super(adaptable, self).__call__(cls, req, **kwargs) + if score == 0 and kwargs.get('rset') and len(kwargs['rset']) > 1 and not 'row' in kwargs: + # on rset containing several entity types, each row may be + # individually adaptable, while the whole rset won't be if the + # same adapter can't be used for each type + for row in range(len(kwargs['rset'])): + kwargs.setdefault('col', 0) + _score = super(adaptable, self).__call__(cls, req, row=row, **kwargs) + if not _score: + return 0 + # adjust score per row as expected by default adjust_score + # implementation + score += self.adjust_score(_score) + else: + score = self.adjust_score(score) + return score + + @staticmethod + def adjust_score(score): + # being adaptable to an interface should takes precedence other + # is_instance('Any'), but not other explicit + # is_instance('SomeEntityType'), and, for **a single entity**: + # * is_instance('Any') score is 1 + # * is_instance('SomeEntityType') score is at least 2 + if score >= 2: + return score - 0.5 + if score == 1: + return score + 0.5 + return score + + +class configuration_values(Predicate): + """Return 1 if the instance has an option set to a given value(s) in its + configuration file. + """ + # XXX this predicate could be evaluated on startup + def __init__(self, key, values): + self._key = key + if not isinstance(values, (tuple, list)): + values = (values,) + self._values = frozenset(values) + + def __call__(self, cls, req, **kwargs): + try: + return self._score + except AttributeError: + if req is None: + config = kwargs['repo'].config + else: + config = req.vreg.config + self._score = config[self._key] in self._values + return self._score + + +# rset predicates ############################################################## + +@objectify_predicate +def none_rset(cls, req, rset=None, **kwargs): + """Return 1 if the result set is None (eg usually not specified).""" + if rset is None: + return 1 + return 0 + + +# XXX == ~ none_rset +@objectify_predicate +def any_rset(cls, req, rset=None, **kwargs): + """Return 1 for any result set, whatever the number of rows in it, even 0.""" + if rset is not None: + return 1 + return 0 + + +@objectify_predicate +def nonempty_rset(cls, req, rset=None, **kwargs): + """Return 1 for result set containing one ore more rows.""" + if rset: + return 1 + return 0 + + +# XXX == ~ nonempty_rset +@objectify_predicate +def empty_rset(cls, req, rset=None, **kwargs): + """Return 1 for result set which doesn't contain any row.""" + if rset is not None and len(rset) == 0: + return 1 + return 0 + + +# XXX == multi_lines_rset(1) +@objectify_predicate +def one_line_rset(cls, req, rset=None, row=None, **kwargs): + """Return 1 if the result set is of size 1, or greater but a specific row in + the result set is specified ('row' argument). + """ + if rset is None and 'entity' in kwargs: + return 1 + if rset is not None and (row is not None or len(rset) == 1): + return 1 + return 0 + + +class multi_lines_rset(Predicate): + """Return 1 if the operator expression matches between `num` elements + in the result set and the `expected` value if defined. + + By default, multi_lines_rset(expected) matches equality expression: + `nb` row(s) in result set equals to expected value + But, you can perform richer comparisons by overriding default operator: + multi_lines_rset(expected, operator.gt) + + If `expected` is None, return 1 if the result set contains *at least* + two rows. + If rset is None, return 0. + """ + def __init__(self, expected=None, operator=eq): + self.expected = expected + self.operator = operator + + def match_expected(self, num): + if self.expected is None: + return num > 1 + return self.operator(num, self.expected) + + def __call__(self, cls, req, rset=None, **kwargs): + return int(rset is not None and self.match_expected(len(rset))) + + +class multi_columns_rset(multi_lines_rset): + """If `nb` is specified, return 1 if the result set has exactly `nb` column + per row. Else (`nb` is None), return 1 if the result set contains *at least* + two columns per row. Return 0 for empty result set. + """ + + def __call__(self, cls, req, rset=None, **kwargs): + # 'or 0' since we *must not* return None. Also don't use rset.rows so + # this selector will work if rset is a simple list of list. + return rset and self.match_expected(len(rset[0])) or 0 + + +class paginated_rset(Predicate): + """Return 1 or more for result set with more rows than one or more page + size. You can specify expected number of pages to the initializer (default + to one), and you'll get that number of pages as score if the result set is + big enough. + + Page size is searched in (respecting order): + * a `page_size` argument + * a `page_size` form parameters + * the `navigation.page-size` property (see :ref:`PersistentProperties`) + """ + def __init__(self, nbpages=1): + assert nbpages > 0 + self.nbpages = nbpages + + def __call__(self, cls, req, rset=None, **kwargs): + if rset is None: + return 0 + page_size = kwargs.get('page_size') + if page_size is None: + page_size = req.form.get('page_size') + if page_size is not None: + try: + page_size = int(page_size) + except ValueError: + page_size = None + if page_size is None: + page_size = req.property_value('navigation.page-size') + if len(rset) <= (page_size*self.nbpages): + return 0 + return self.nbpages + + +@objectify_predicate +def sorted_rset(cls, req, rset=None, **kwargs): + """Return 1 for sorted result set (e.g. from an RQL query containing an + ORDERBY clause), with exception that it will return 0 if the rset is + 'ORDERBY FTIRANK(VAR)' (eg sorted by rank value of the has_text index). + """ + if rset is None: + return 0 + selects = rset.syntax_tree().children + if (len(selects) > 1 or + not selects[0].orderby or + (isinstance(selects[0].orderby[0].term, Function) and + selects[0].orderby[0].term.name == 'FTIRANK') + ): + return 0 + return 2 + + +# XXX == multi_etypes_rset(1) +@objectify_predicate +def one_etype_rset(cls, req, rset=None, col=0, **kwargs): + """Return 1 if the result set contains entities which are all of the same + type in the column specified by the `col` argument of the input context, or + in column 0. + """ + if rset is None: + return 0 + if len(rset.column_types(col)) != 1: + return 0 + return 1 + + +class multi_etypes_rset(multi_lines_rset): + """If `nb` is specified, return 1 if the result set contains `nb` different + types of entities in the column specified by the `col` argument of the input + context, or in column 0. If `nb` is None, return 1 if the result set contains + *at least* two different types of entities. + """ + + def __call__(self, cls, req, rset=None, col=0, **kwargs): + # 'or 0' since we *must not* return None + return rset and self.match_expected(len(rset.column_types(col))) or 0 + + +@objectify_predicate +def logged_user_in_rset(cls, req, rset=None, row=None, col=0, **kwargs): + """Return positive score if the result set at the specified row / col + contains the eid of the logged user. + """ + if rset is None: + return 0 + return req.user.eid == rset[row or 0][col] + + +# entity predicates ############################################################# + +class composite_etype(Predicate): + """Return 1 for composite entities. + + A composite entity has an etype for which at least one relation + definition points in its direction with the + composite='subject'/'object' notation. + """ + + def __call__(self, cls, req, **kwargs): + entity = kwargs.pop('entity', None) + if entity is None: + return 0 + return entity.e_schema.is_composite + + + +class non_final_entity(EClassPredicate): + """Return 1 for entity of a non final entity type(s). Remember, "final" + entity types are String, Int, etc... This is equivalent to + `is_instance('Any')` but more optimized. + + See :class:`~cubicweb.predicates.EClassPredicate` documentation for entity + class lookup / score rules according to the input context. + """ + def score(self, cls, req, etype): + if etype in BASE_TYPES: + return 0 + return 1 + + def score_class(self, eclass, req): + return 1 # necessarily true if we're there + + + +def _reset_is_instance_cache(vreg): + vreg._is_instance_predicate_cache = {} + +CW_EVENT_MANAGER.bind('before-registry-reset', _reset_is_instance_cache) + +class is_instance(EClassPredicate): + """Return non-zero score for entity that is an instance of the one of given + type(s). If multiple arguments are given, matching one of them is enough. + + Entity types should be given as string, the corresponding class will be + fetched from the registry at selection time. + + See :class:`~cubicweb.predicates.EClassPredicate` documentation for entity + class lookup / score rules according to the input context. + + .. note:: the score will reflect class proximity so the most specific object + will be selected. + """ + + def __init__(self, *expected_etypes, **kwargs): + super(is_instance, self).__init__(**kwargs) + self.expected_etypes = expected_etypes + for etype in self.expected_etypes: + assert isinstance(etype, string_types), etype + + def __str__(self): + return '%s(%s)' % (self.__class__.__name__, + ','.join(str(s) for s in self.expected_etypes)) + + def score_class(self, eclass, req): + # cache on vreg to avoid reloading issues + try: + cache = req.vreg._is_instance_predicate_cache + except AttributeError: + # XXX 'before-registry-reset' not called for db-api connections + cache = req.vreg._is_instance_predicate_cache = {} + try: + expected_eclasses = cache[self] + except KeyError: + # turn list of entity types as string into a list of + # (entity class, parent classes) + etypesreg = req.vreg['etypes'] + expected_eclasses = cache[self] = [] + for etype in self.expected_etypes: + try: + expected_eclasses.append(etypesreg.etype_class(etype)) + except KeyError: + continue # entity type not in the schema + parents, any = req.vreg['etypes'].parent_classes(eclass.__regid__) + score = 0 + for expectedcls in expected_eclasses: + # adjust score according to class proximity + if expectedcls is eclass: + score += len(parents) + 4 + elif expectedcls is any: # Any + score += 1 + else: + for index, basecls in enumerate(reversed(parents)): + if expectedcls is basecls: + score += index + 3 + break + return score + + +class score_entity(EntityPredicate): + """Return score according to an arbitrary function given as argument which + will be called with input content entity as argument. + + This is a very useful predicate that will usually interest you since it + allows a lot of things without having to write a specific predicate. + + The function can return arbitrary value which will be casted to an integer + value at the end. + + See :class:`~cubicweb.predicates.EntityPredicate` documentation for entity + lookup / score rules according to the input context. + """ + def __init__(self, scorefunc, once_is_enough=None, mode='all'): + super(score_entity, self).__init__(mode=mode, once_is_enough=once_is_enough) + def intscore(*args, **kwargs): + score = scorefunc(*args, **kwargs) + if not score: + return 0 + if isinstance(score, integer_types): + return score + return 1 + self.score_entity = intscore + + +class has_mimetype(EntityPredicate): + """Return 1 if the entity adapt to IDownloadable and has the given MIME type. + + You can give 'image/' to match any image for instance, or 'image/png' to match + only PNG images. + """ + def __init__(self, mimetype, once_is_enough=None, mode='all'): + super(has_mimetype, self).__init__(mode=mode, once_is_enough=once_is_enough) + self.mimetype = mimetype + + def score_entity(self, entity): + idownloadable = entity.cw_adapt_to('IDownloadable') + if idownloadable is None: + return 0 + mt = idownloadable.download_content_type() + if not (mt and mt.startswith(self.mimetype)): + return 0 + return 1 + + +class relation_possible(EntityPredicate): + """Return 1 for entity that supports the relation, provided that the + request's user may do some `action` on it (see below). + + The relation is specified by the following initializer arguments: + + * `rtype`, the name of the relation + + * `role`, the role of the entity in the relation, either 'subject' or + 'object', default to 'subject' + + * `target_etype`, optional name of an entity type that should be supported + at the other end of the relation + + * `action`, a relation schema action (e.g. one of 'read', 'add', 'delete', + default to 'read') which must be granted to the user, else a 0 score will + be returned. Give None if you don't want any permission checking. + + * `strict`, boolean (default to False) telling what to do when the user has + not globally the permission for the action (eg the action is not granted + to one of the user's groups) + + - when strict is False, if there are some local role defined for this + action (e.g. using rql expressions), then the permission will be + considered as granted + + - when strict is True, then the permission will be actually checked for + each entity + + Setting `strict` to True impacts performance for large result set since + you'll then get the :class:`~cubicweb.predicates.EntityPredicate` behaviour + while otherwise you get the :class:`~cubicweb.predicates.EClassPredicate`'s + one. See those classes documentation for entity lookup / score rules + according to the input context. + """ + + def __init__(self, rtype, role='subject', target_etype=None, + action='read', strict=False, **kwargs): + super(relation_possible, self).__init__(**kwargs) + self.rtype = rtype + self.role = role + self.target_etype = target_etype + self.action = action + self.strict = strict + + # hack hack hack + def __call__(self, cls, req, **kwargs): + # hack hack hack + if self.strict: + return EntityPredicate.__call__(self, cls, req, **kwargs) + return EClassPredicate.__call__(self, cls, req, **kwargs) + + def score(self, *args): + if self.strict: + return EntityPredicate.score(self, *args) + return EClassPredicate.score(self, *args) + + def _get_rschema(self, eclass): + eschema = eclass.e_schema + try: + if self.role == 'object': + return eschema.objrels[self.rtype] + else: + return eschema.subjrels[self.rtype] + except KeyError: + return None + + def score_class(self, eclass, req): + rschema = self._get_rschema(eclass) + if rschema is None: + return 0 # relation not supported + eschema = eclass.e_schema + if self.target_etype is not None: + try: + rdef = rschema.role_rdef(eschema, self.target_etype, self.role) + except KeyError: + return 0 + if self.action and not rdef.may_have_permission(self.action, req): + return 0 + teschema = req.vreg.schema.eschema(self.target_etype) + if not teschema.may_have_permission('read', req): + return 0 + elif self.action: + return rschema.may_have_permission(self.action, req, eschema, self.role) + return 1 + + def score_entity(self, entity): + rschema = self._get_rschema(entity) + if rschema is None: + return 0 # relation not supported + if self.action: + if self.target_etype is not None: + try: + rschema = rschema.role_rdef(entity.e_schema, + self.target_etype, self.role) + except KeyError: + return 0 + if self.role == 'subject': + if not rschema.has_perm(entity._cw, self.action, fromeid=entity.eid): + return 0 + elif not rschema.has_perm(entity._cw, self.action, toeid=entity.eid): + return 0 + if self.target_etype is not None: + req = entity._cw + teschema = req.vreg.schema.eschema(self.target_etype) + if not teschema.may_have_permission('read', req): + return 0 + return 1 + + +class partial_relation_possible(PartialPredicateMixIn, relation_possible): + """Same as :class:~`cubicweb.predicates.relation_possible`, but will look for + attributes of the selected class to get information which is otherwise + expected by the initializer, except for `action` and `strict` which are kept + as initializer arguments. + + This is useful to predefine predicate of an abstract class designed to be + customized. + """ + def __init__(self, action='read', **kwargs): + super(partial_relation_possible, self).__init__(None, None, None, + action, **kwargs) + + def complete(self, cls): + self.rtype = cls.rtype + self.role = role(cls) + self.target_etype = getattr(cls, 'target_etype', None) + + +class has_related_entities(EntityPredicate): + """Return 1 if entity support the specified relation and has some linked + entities by this relation , optionally filtered according to the specified + target type. + + The relation is specified by the following initializer arguments: + + * `rtype`, the name of the relation + + * `role`, the role of the entity in the relation, either 'subject' or + 'object', default to 'subject'. + + * `target_etype`, optional name of an entity type that should be found + at the other end of the relation + + See :class:`~cubicweb.predicates.EntityPredicate` documentation for entity + lookup / score rules according to the input context. + """ + def __init__(self, rtype, role='subject', target_etype=None, **kwargs): + super(has_related_entities, self).__init__(**kwargs) + self.rtype = rtype + self.role = role + self.target_etype = target_etype + + def score_entity(self, entity): + relpossel = relation_possible(self.rtype, self.role, self.target_etype) + if not relpossel.score_class(entity.__class__, entity._cw): + return 0 + rset = entity.related(self.rtype, self.role) + if self.target_etype: + return any(r for r in rset.description if r[0] == self.target_etype) + return rset and 1 or 0 + + +class partial_has_related_entities(PartialPredicateMixIn, has_related_entities): + """Same as :class:~`cubicweb.predicates.has_related_entity`, but will look + for attributes of the selected class to get information which is otherwise + expected by the initializer. + + This is useful to predefine predicate of an abstract class designed to be + customized. + """ + def __init__(self, **kwargs): + super(partial_has_related_entities, self).__init__(None, None, None, + **kwargs) + + def complete(self, cls): + self.rtype = cls.rtype + self.role = role(cls) + self.target_etype = getattr(cls, 'target_etype', None) + + +class has_permission(EntityPredicate): + """Return non-zero score if request's user has the permission to do the + requested action on the entity. `action` is an entity schema action (eg one + of 'read', 'add', 'delete', 'update'). + + Here are entity lookup / scoring rules: + + * if `entity` is specified, check permission is granted for this entity + + * elif `row` is specified, check permission is granted for the entity found + in the specified cell + + * else check permission is granted for each entity found in the column + specified specified by the `col` argument or in column 0 + """ + def __init__(self, action): + self.action = action + + # don't use EntityPredicate.__call__ but this optimized implementation to + # avoid considering each entity when it's not necessary + def __call__(self, cls, req, rset=None, row=None, col=0, entity=None, **kwargs): + if entity is not None: + return self.score_entity(entity) + if rset is None: + return 0 + if row is None: + score = 0 + need_local_check = [] + geteschema = req.vreg.schema.eschema + user = req.user + action = self.action + for etype in rset.column_types(0): + if etype in BASE_TYPES: + return 0 + eschema = geteschema(etype) + if not user.matching_groups(eschema.get_groups(action)): + if eschema.has_local_role(action): + # have to ckeck local roles + need_local_check.append(eschema) + continue + else: + # even a local role won't be enough + return 0 + score += 1 + if need_local_check: + # check local role for entities of necessary types + for i, row in enumerate(rset): + if not rset.description[i][col] in need_local_check: + continue + # micro-optimisation instead of calling self.score(req, + # rset, i, col): rset may be large + if not rset.get_entity(i, col).cw_has_perm(action): + return 0 + score += 1 + return score + return self.score(req, rset, row, col) + + def score_entity(self, entity): + if entity.cw_has_perm(self.action): + return 1 + return 0 + + +class has_add_permission(EClassPredicate): + """Return 1 if request's user has the add permission on entity type + specified in the `etype` initializer argument, or according to entity found + in the input content if not specified. + + It also check that then entity type is not a strict subobject (e.g. may only + be used as a composed of another entity). + + See :class:`~cubicweb.predicates.EClassPredicate` documentation for entity + class lookup / score rules according to the input context when `etype` is + not specified. + """ + def __init__(self, etype=None, **kwargs): + super(has_add_permission, self).__init__(**kwargs) + self.etype = etype + + def __call__(self, cls, req, **kwargs): + if self.etype is None: + return super(has_add_permission, self).__call__(cls, req, **kwargs) + return self.score(cls, req, self.etype) + + def score_class(self, eclass, req): + eschema = eclass.e_schema + if eschema.final or eschema.is_subobject(strict=True) \ + or not eschema.has_perm(req, 'add'): + return 0 + return 1 + + +class rql_condition(EntityPredicate): + """Return non-zero score if arbitrary rql specified in `expression` + initializer argument return some results for entity found in the input + context. Returned score is the number of items returned by the rql + condition. + + `expression` is expected to be a string containing an rql expression, which + must use 'X' variable to represent the context entity and may use 'U' to + represent the request's user. + + .. warning:: + If simply testing value of some attribute/relation of context entity (X), + you should rather use the :class:`score_entity` predicate which will + benefit from the ORM's request entities cache. + + See :class:`~cubicweb.predicates.EntityPredicate` documentation for entity + lookup / score rules according to the input context. + """ + def __init__(self, expression, once_is_enough=None, mode='all', user_condition=False): + super(rql_condition, self).__init__(mode=mode, once_is_enough=once_is_enough) + self.user_condition = user_condition + if user_condition: + rql = 'Any COUNT(U) WHERE U eid %%(u)s, %s' % expression + elif 'U' in frozenset(split_expression(expression)): + rql = 'Any COUNT(X) WHERE X eid %%(x)s, U eid %%(u)s, %s' % expression + else: + rql = 'Any COUNT(X) WHERE X eid %%(x)s, %s' % expression + self.rql = rql + + def __str__(self): + return '%s(%r)' % (self.__class__.__name__, self.rql) + + def __call__(self, cls, req, **kwargs): + if self.user_condition: + try: + return req.execute(self.rql, {'u': req.user.eid})[0][0] + except Unauthorized: + return 0 + else: + return super(rql_condition, self).__call__(cls, req, **kwargs) + + def _score(self, req, eid): + try: + return req.execute(self.rql, {'x': eid, 'u': req.user.eid})[0][0] + except Unauthorized: + return 0 + + def score(self, req, rset, row, col): + return self._score(req, rset[row][col]) + + def score_entity(self, entity): + return self._score(entity._cw, entity.eid) + + +# workflow predicates ########################################################### + +class is_in_state(score_entity): + """Return 1 if entity is in one of the states given as argument list + + You should use this instead of your own :class:`score_entity` predicate to + avoid some gotchas: + + * possible views gives a fake entity with no state + * you must use the latest tr info thru the workflow adapter for repository + side checking of the current state + + In debug mode, this predicate can raise :exc:`ValueError` for unknown states names + (only checked on entities without a custom workflow) + + :rtype: int + """ + def __init__(self, *expected): + assert expected, self + self.expected = frozenset(expected) + def score(entity, expected=self.expected): + adapted = entity.cw_adapt_to('IWorkflowable') + # in debug mode only (time consuming) + if entity._cw.vreg.config.debugmode: + # validation can only be done for generic etype workflow because + # expected transition list could have been changed for a custom + # workflow (for the current entity) + if not entity.custom_workflow: + self._validate(adapted) + return self._score(adapted) + super(is_in_state, self).__init__(score) + + def _score(self, adapted): + trinfo = adapted.latest_trinfo() + if trinfo is None: # entity is probably in it's initial state + statename = adapted.state + else: + statename = trinfo.new_state.name + return statename in self.expected + + def _validate(self, adapted): + wf = adapted.current_workflow + valid = [n.name for n in wf.reverse_state_of] + unknown = sorted(self.expected.difference(valid)) + if unknown: + raise ValueError("%s: unknown state(s): %s" + % (wf.name, ",".join(unknown))) + + def __str__(self): + return '%s(%s)' % (self.__class__.__name__, + ','.join(str(s) for s in self.expected)) + + +def on_fire_transition(etype, tr_names, from_state_name=None): + """Return 1 when entity of the type `etype` is going through transition of + a name included in `tr_names`. + + You should use this predicate on 'after_add_entity' hook, since it's actually + looking for addition of `TrInfo` entities. Hence in the hook, `self.entity` + will reference the matching `TrInfo` entity, allowing to get all the + transition details (including the entity to which is applied the transition + but also its original state, transition, destination state, user...). + + See :class:`cubicweb.entities.wfobjs.TrInfo` for more information. + """ + if from_state_name is not None: + warn("on_fire_transition's from_state_name argument is unused", DeprecationWarning) + if isinstance(tr_names, string_types): + tr_names = set((tr_names,)) + def match_etype_and_transition(trinfo): + # take care trinfo.transition is None when calling change_state + return (trinfo.transition and trinfo.transition.name in tr_names + # is_instance() first two arguments are 'cls' (unused, so giving + # None is fine) and the request/session + and is_instance(etype)(None, trinfo._cw, entity=trinfo.for_entity)) + + return is_instance('TrInfo') & score_entity(match_etype_and_transition) + + +class match_transition(ExpectedValuePredicate): + """Return 1 if `transition` argument is found in the input context which has + a `.name` attribute matching one of the expected names given to the + initializer. + + This predicate is expected to be used to customise the status change form in + the web ui. + """ + def __call__(self, cls, req, transition=None, **kwargs): + # XXX check this is a transition that apply to the object? + if transition is None: + treid = req.form.get('treid', None) + if treid: + transition = req.entity_from_eid(treid) + if transition is not None and getattr(transition, 'name', None) in self.expected: + return 1 + return 0 + + +# logged user predicates ######################################################## + +@objectify_predicate +def no_cnx(cls, req, **kwargs): + """Return 1 if the web session has no connection set. This occurs when + anonymous access is not allowed and user isn't authenticated. + """ + if not req.cnx: + return 1 + return 0 + + +@objectify_predicate +def authenticated_user(cls, req, **kwargs): + """Return 1 if the user is authenticated (i.e. not the anonymous user). + """ + if req.session.anonymous_session: + return 0 + return 1 + + +@objectify_predicate +def anonymous_user(cls, req, **kwargs): + """Return 1 if the user is not authenticated (i.e. is the anonymous user). + """ + if req.session.anonymous_session: + return 1 + return 0 + + +class match_user_groups(ExpectedValuePredicate): + """Return a non-zero score if request's user is in at least one of the + groups given as initializer argument. Returned score is the number of groups + in which the user is. + + If the special 'owners' group is given and `rset` is specified in the input + context: + + * if `row` is specified check the entity at the given `row`/`col` (default + to 0) is owned by the user + + * else check all entities in `col` (default to 0) are owned by the user + """ + + def __call__(self, cls, req, rset=None, row=None, col=0, **kwargs): + if not getattr(req, 'cnx', True): # default to True for repo session instances + return 0 + user = req.user + if user is None: + return int('guests' in self.expected) + score = user.matching_groups(self.expected) + if not score and 'owners' in self.expected and rset: + if row is not None: + if not user.owns(rset[row][col]): + return 0 + score = 1 + else: + score = all(user.owns(r[col]) for r in rset) + return score + +# Web request predicates ######################################################## + +# XXX deprecate +@objectify_predicate +def primary_view(cls, req, view=None, **kwargs): + """Return 1 if: + + * *no view is specified* in the input context + + * a view is specified and its `.is_primary()` method return True + + This predicate is usually used by contextual components that only want to + appears for the primary view of an entity. + """ + if view is not None and not view.is_primary(): + return 0 + return 1 + + +@objectify_predicate +def contextual(cls, req, view=None, **kwargs): + """Return 1 if view's contextual property is true""" + if view is not None and view.contextual: + return 1 + return 0 + + +class match_view(ExpectedValuePredicate): + """Return 1 if a view is specified an as its registry id is in one of the + expected view id given to the initializer. + """ + def __call__(self, cls, req, view=None, **kwargs): + if view is None or not view.__regid__ in self.expected: + return 0 + return 1 + + +class match_context(ExpectedValuePredicate): + + def __call__(self, cls, req, context=None, **kwargs): + if not context in self.expected: + return 0 + return 1 + + +# XXX deprecate +@objectify_predicate +def match_context_prop(cls, req, context=None, **kwargs): + """Return 1 if: + + * no `context` is specified in input context (take care to confusion, here + `context` refers to a string given as an argument to the input context...) + + * specified `context` is matching the context property value for the + appobject using this predicate + + * the appobject's context property value is None + + This predicate is usually used by contextual components that want to appears + in a configurable place. + """ + if context is None: + return 1 + propval = req.property_value('%s.%s.context' % (cls.__registry__, + cls.__regid__)) + if propval and context != propval: + return 0 + return 1 + + +class match_search_state(ExpectedValuePredicate): + """Return 1 if the current request search state is in one of the expected + states given to the initializer. + + Known search states are either 'normal' or 'linksearch' (eg searching for an + object to create a relation with another). + + This predicate is usually used by action that want to appears or not according + to the ui search state. + """ + + def __call__(self, cls, req, **kwargs): + try: + if not req.search_state[0] in self.expected: + return 0 + except AttributeError: + return 1 # class doesn't care about search state, accept it + return 1 + + +class match_form_params(ExpectedValuePredicate): + """Return non-zero score if parameter names specified as initializer + arguments are specified in request's form parameters. + + Return a score corresponding to the number of expected parameters. + + When multiple parameters are expected, all of them should be found in + the input context unless `mode` keyword argument is given to 'any', + in which case a single matching parameter is enough. + """ + + def __init__(self, *expected, **kwargs): + """override default __init__ to allow either named or positional + parameters. + """ + if kwargs and expected: + raise ValueError("match_form_params() can't be called with both " + "positional and named arguments") + if expected: + if len(expected) == 1 and not isinstance(expected[0], string_types): + raise ValueError("match_form_params() positional arguments " + "must be strings") + super(match_form_params, self).__init__(*expected) + else: + super(match_form_params, self).__init__(kwargs) + + def _values_set(self, cls, req, **kwargs): + return req.form + + +class match_http_method(ExpectedValuePredicate): + """Return non-zero score if one of the HTTP methods specified as + initializer arguments is the HTTP method of the request (GET, POST, ...). + """ + + def __call__(self, cls, req, **kwargs): + return int(req.http_method() in self.expected) + + +class match_edited_type(ExpectedValuePredicate): + """return non-zero if main edited entity type is the one specified as + initializer argument, or is among initializer arguments if `mode` == 'any'. + """ + + def _values_set(self, cls, req, **kwargs): + try: + return frozenset((req.form['__type:%s' % req.form['__maineid']],)) + except KeyError: + return frozenset() + + +class match_form_id(ExpectedValuePredicate): + """return non-zero if request form identifier is the one specified as + initializer argument, or is among initializer arguments if `mode` == 'any'. + """ + + def _values_set(self, cls, req, **kwargs): + try: + return frozenset((req.form['__form_id'],)) + except KeyError: + return frozenset() + + +class specified_etype_implements(is_instance): + """Return non-zero score if the entity type specified by an 'etype' key + searched in (by priority) input context kwargs and request form parameters + match a known entity type (case insensitivly), and it's associated entity + class is of one of the type(s) given to the initializer. If multiple + arguments are given, matching one of them is enough. + + .. note:: as with :class:`~cubicweb.predicates.is_instance`, entity types + should be given as string and the score will reflect class + proximity so the most specific object will be selected. + + This predicate is usually used by views holding entity creation forms (since + we've no result set to work on). + """ + + def __call__(self, cls, req, **kwargs): + try: + etype = kwargs['etype'] + except KeyError: + try: + etype = req.form['etype'] + except KeyError: + return 0 + else: + # only check this is a known type if etype comes from req.form, + # else we want the error to propagate + try: + etype = req.vreg.case_insensitive_etypes[etype.lower()] + req.form['etype'] = etype + except KeyError: + return 0 + score = self.score_class(req.vreg['etypes'].etype_class(etype), req) + if score: + eschema = req.vreg.schema.eschema(etype) + if eschema.may_have_permission('add', req): + return score + return 0 + + +class attribute_edited(EntityPredicate): + """Scores if the specified attribute has been edited This is useful for + selection of forms by the edit controller. + + The initial use case is on a form, in conjunction with match_transition, + which will not score at edit time:: + + is_instance('Version') & (match_transition('ready') | + attribute_edited('publication_date')) + """ + def __init__(self, attribute, once_is_enough=None, mode='all'): + super(attribute_edited, self).__init__(mode=mode, once_is_enough=once_is_enough) + self._attribute = attribute + + def score_entity(self, entity): + return eid_param(role_name(self._attribute, 'subject'), entity.eid) in entity._cw.form + + +# Other predicates ############################################################## + +class match_exception(ExpectedValuePredicate): + """Return 1 if exception given as `exc` in the input context is an instance + of one of the class given on instanciation of this predicate. + """ + def __init__(self, *expected): + assert expected, self + # we want a tuple, not a set as done in the parent class + self.expected = expected + + def __call__(self, cls, req, exc=None, **kwargs): + if exc is not None and isinstance(exc, self.expected): + return 1 + return 0 + + +@objectify_predicate +def debug_mode(cls, req, rset=None, **kwargs): + """Return 1 if running in debug mode.""" + return req.vreg.config.debugmode and 1 or 0