entity.py 56.5 KB
Newer Older
Sylvain Thénault's avatar
Sylvain Thénault committed
1
# copyright 2003-2016 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
2
3
4
5
6
7
8
9
10
# 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.
#
11
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
12
13
14
15
16
17
# 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 <http://www.gnu.org/licenses/>.
18
"""Base class for entity objects manipulated in clients"""
Adrien Di Mascio's avatar
Adrien Di Mascio committed
19

20
from six import text_type, string_types, integer_types
21
22
from six.moves import range

Adrien Di Mascio's avatar
Adrien Di Mascio committed
23
from logilab.common.decorators import cached
24
from logilab.common.registry import yes
Pierre-Yves David's avatar
Pierre-Yves David committed
25
from logilab.mtconverter import TransformData, xml_escape
26

Adrien Di Mascio's avatar
Adrien Di Mascio committed
27
from rql.utils import rqlvar_maker
28
29
30
from rql.stmts import Select
from rql.nodes import (Not, VariableRef, Constant, make_relation,
                       Relation as RqlRelation)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
31

32
from cubicweb import Unauthorized, neg_role
33
from cubicweb.utils import support_args
Adrien Di Mascio's avatar
Adrien Di Mascio committed
34
from cubicweb.rset import ResultSet
35
from cubicweb.appobject import AppObject
36
37
from cubicweb.schema import (RQLVocabularyConstraint, RQLConstraint,
                             GeneratedConstraint)
38
from cubicweb.rqlrewrite import RQLRewriter
39

40
from cubicweb.uilib import soup2xhtml
Sylvain Thénault's avatar
Sylvain Thénault committed
41
from cubicweb.mttransforms import ENGINE
Adrien Di Mascio's avatar
Adrien Di Mascio committed
42
43
44

_marker = object()

Sylvain Thénault's avatar
Sylvain Thénault committed
45

Adrien Di Mascio's avatar
Adrien Di Mascio committed
46
47
48
def greater_card(rschema, subjtypes, objtypes, index):
    for subjtype in subjtypes:
        for objtype in objtypes:
Sylvain Thénault's avatar
Sylvain Thénault committed
49
            card = rschema.rdef(subjtype, objtype).cardinality[index]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
50
51
52
53
            if card in '+*':
                return card
    return '1'

54
55
56
57
def can_use_rest_path(value):
    """return True if value can be used at the end of a Rest URL path"""
    if value is None:
        return False
58
    value = text_type(value)
59
60
61
62
63
64
    # the check for ?, /, & are to prevent problems when running
    # behind Apache mod_proxy
    if value == u'' or u'?' in value or u'/' in value or u'&' in value:
        return False
    return True

65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
def rel_vars(rel):
    return ((isinstance(rel.children[0], VariableRef)
             and rel.children[0].variable or None),
            (isinstance(rel.children[1].children[0], VariableRef)
             and rel.children[1].children[0].variable or None)
            )

def rel_matches(rel, rtype, role, varname, operator='='):
    if rel.r_type == rtype and rel.children[1].operator == operator:
        same_role_var_idx = 0 if role == 'subject' else 1
        variables = rel_vars(rel)
        if variables[same_role_var_idx].name == varname:
            return variables[1 - same_role_var_idx]

def build_cstr_with_linkto_infos(cstr, args, searchedvar, evar,
                                 lt_infos, eidvars):
    """restrict vocabulary as much as possible in entity creation,
    based on infos provided by __linkto form param.

    Example based on following schema:

      class works_in(RelationDefinition):
          subject = 'CWUser'
          object = 'Lab'
          cardinality = '1*'
          constraints = [RQLConstraint('S in_group G, O welcomes G')]

      class welcomes(RelationDefinition):
          subject = 'Lab'
          object = 'CWGroup'

    If you create a CWUser in the "scientists" CWGroup you can show
    only the labs that welcome them using :

      lt_infos = {('in_group', 'subject'): 321}

    You get following restriction : 'O welcomes G, G eid 321'

    """
    st = cstr.snippet_rqlst.copy()
    # replace relations in ST by eid infos from linkto where possible
106
    for (info_rtype, info_role), eids in lt_infos.items():
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
        eid = eids[0] # NOTE: we currently assume a pruned lt_info with only 1 eid
        for rel in st.iget_nodes(RqlRelation):
            targetvar = rel_matches(rel, info_rtype, info_role, evar.name)
            if targetvar is not None:
                if targetvar.name in eidvars:
                    rel.parent.remove(rel)
                else:
                    eidrel = make_relation(
                        targetvar, 'eid', (targetvar.name, 'Substitute'),
                        Constant)
                    rel.parent.replace(rel, eidrel)
                    args[targetvar.name] = eid
                    eidvars.add(targetvar.name)
    # if modified ST still contains evar references we must discard the
    # constraint, otherwise evar is unknown in the final rql query which can
    # lead to a SQL table cartesian product and multiple occurences of solutions
    evarname = evar.name
    for rel in st.iget_nodes(RqlRelation):
        for variable in rel_vars(rel):
            if variable and evarname == variable.name:
                return
    # else insert snippets into the global tree
    return GeneratedConstraint(st, cstr.mainvars - set(evarname))

def pruned_lt_info(eschema, lt_infos):
    pruned = {}
133
    for (lt_rtype, lt_role), eids in lt_infos.items():
134
135
136
137
138
139
140
141
142
143
        # we can only use lt_infos describing relation with a cardinality
        # of value 1 towards the linked entity
        if not len(eids) == 1:
            continue
        lt_card = eschema.rdef(lt_rtype, lt_role).cardinality[
            0 if lt_role == 'subject' else 1]
        if lt_card not in '?1':
            continue
        pruned[(lt_rtype, lt_role)] = eids
    return pruned
144

145

146
class Entity(AppObject):
147
148
    """an entity instance has e_schema automagically set on
    the class and instances has access to their issuing cursor.
149

150
    A property is set for each attribute and relation on each entity's type
Sylvain Thénault's avatar
Sylvain Thénault committed
151
    class. Becare that among attributes, 'eid' is *NEVER* stored in the
152
153
    dict containment (which acts as a cache for other attributes dynamically
    fetched)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
154
155
156
157

    :type e_schema: `cubicweb.schema.EntitySchema`
    :ivar e_schema: the entity's schema

158
159
160
161
    :type rest_attr: str
    :cvar rest_attr: indicates which attribute should be used to build REST urls
       If `None` is specified (the default), the first unique attribute will
       be used ('eid' if none found)
162

163
164
    :type cw_skip_copy_for: list
    :cvar cw_skip_copy_for: a list of couples (rtype, role) for each relation
165
166
167
       that should be skipped when copying this kind of entity. Note that some
       relations such as composite relations or relations that have '?1' as
       object cardinality are always skipped.
Adrien Di Mascio's avatar
Adrien Di Mascio committed
168
169
    """
    __registry__ = 'etypes'
170
    __select__ = yes()
171

172
    # class attributes that must be set in class definition
Adrien Di Mascio's avatar
Adrien Di Mascio committed
173
    rest_attr = None
174
    fetch_attrs = None
175
    cw_skip_copy_for = [('in_state', 'subject')]
176
177
    # class attributes set automatically at registration time
    e_schema = None
178

Adrien Di Mascio's avatar
Adrien Di Mascio committed
179
    @classmethod
180
    def __initialize__(cls, schema):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
181
182
183
        """initialize a specific entity class by adding descriptors to access
        entity type's attributes and relations
        """
184
        etype = cls.__regid__
Adrien Di Mascio's avatar
Adrien Di Mascio committed
185
        assert etype != 'Any', etype
186
        cls.e_schema = eschema = schema.eschema(etype)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
187
        for rschema, _ in eschema.attribute_definitions():
188
            if rschema.type == 'eid':
Adrien Di Mascio's avatar
Adrien Di Mascio committed
189
                continue
190
            setattr(cls, rschema.type, Attribute(rschema.type))
Adrien Di Mascio's avatar
Adrien Di Mascio committed
191
        mixins = []
192
193
        for rschema, _, role in eschema.relation_definitions():
            if role == 'subject':
194
                attr = rschema.type
Adrien Di Mascio's avatar
Adrien Di Mascio committed
195
            else:
196
                attr = 'reverse_%s' % rschema.type
197
            setattr(cls, attr, Relation(rschema, role))
198

199
    fetch_attrs = ('modification_date',)
200

201
    @classmethod
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
    def cw_fetch_order(cls, select, attr, var):
        """This class method may be used to control sort order when multiple
        entities of this type are fetched through ORM methods. Its arguments
        are:

        * `select`, the RQL syntax tree

        * `attr`, the attribute being watched

        * `var`, the variable through which this attribute's value may be
          accessed in the query

        When you want to do some sorting on the given attribute, you should
        modify the syntax tree accordingly. For instance:

        .. sourcecode:: python

          from rql import nodes

          class Version(AnyEntity):
              __regid__ = 'Version'

              fetch_attrs = ('num', 'description', 'in_state')

              @classmethod
              def cw_fetch_order(cls, select, attr, var):
                  if attr == 'num':
                      func = nodes.Function('version_sort_value')
                      func.append(nodes.variable_ref(var))
                      sterm = nodes.SortTerm(func, asc=False)
                      select.add_sort_term(sterm)

        The default implementation call
        :meth:`~cubicweb.entity.Entity.cw_fetch_unrelated_order`
236
        """
237
        cls.cw_fetch_unrelated_order(select, attr, var)
238
239

    @classmethod
240
241
242
    def cw_fetch_unrelated_order(cls, select, attr, var):
        """This class method may be used to control sort order when multiple entities of
        this type are fetched to use in edition (e.g. propose them to create a
243
        new relation on an edited entity).
244
245
246
247
248
249

        See :meth:`~cubicweb.entity.Entity.cw_fetch_unrelated_order` for a
        description of its arguments and usage.

        By default entities will be listed on their modification date descending,
        i.e. you'll get entities recently modified first.
250
251
        """
        if attr == 'modification_date':
252
            select.add_sort_var(var, asc=False)
253

Adrien Di Mascio's avatar
Adrien Di Mascio committed
254
    @classmethod
Denis Laxalde's avatar
Denis Laxalde committed
255
    def fetch_rql(cls, user, fetchattrs=None, mainvar='X',
Adrien Di Mascio's avatar
Adrien Di Mascio committed
256
                  settype=True, ordermethod='fetch_order'):
257
258
        st = cls.fetch_rqlst(user, mainvar=mainvar, fetchattrs=fetchattrs,
                             settype=settype, ordermethod=ordermethod)
Denis Laxalde's avatar
Denis Laxalde committed
259
        return st.as_string()
260
261
262
263
264
265
266
267

    @classmethod
    def fetch_rqlst(cls, user, select=None, mainvar='X', fetchattrs=None,
                    settype=True, ordermethod='fetch_order'):
        if select is None:
            select = Select()
            mainvar = select.get_variable(mainvar)
            select.add_selected(mainvar)
268
        elif isinstance(mainvar, string_types):
269
270
271
272
273
            assert mainvar in select.defined_vars
            mainvar = select.get_variable(mainvar)
        # eases string -> syntax tree test transition: please remove once stable
        select._varmaker = rqlvar_maker(defined=select.defined_vars,
                                        aliases=select.aliases, index=26)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
274
        if settype:
275
276
277
278
            rel = select.add_type_restriction(mainvar, cls.__regid__)
            # should use 'is_instance_of' instead of 'is' so we retrieve
            # subclasses instances as well
            rel.r_type = 'is_instance_of'
Adrien Di Mascio's avatar
Adrien Di Mascio committed
279
280
        if fetchattrs is None:
            fetchattrs = cls.fetch_attrs
281
282
        cls._fetch_restrictions(mainvar, select, fetchattrs, user, ordermethod)
        return select
283

284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
    @classmethod
    def _fetch_ambiguous_rtypes(cls, select, var, fetchattrs, subjtypes, schema):
        """find rtypes in `fetchattrs` that relate different subject etypes
        taken from (`subjtypes`) to different target etypes; these so called
        "ambiguous" relations, are added directly to the `select` syntax tree
        selection but removed from `fetchattrs` to avoid the fetch recursion
        because we have to choose only one targettype for the recursion and
        adding its own fetch attrs to the selection -when we recurse- would
        filter out the other possible target types from the result set
        """
        for attr in fetchattrs.copy():
            rschema = schema.rschema(attr)
            if rschema.final:
                continue
            ttypes = None
            for subjtype in subjtypes:
                cur_ttypes = set(rschema.objects(subjtype))
                if ttypes is None:
                    ttypes = cur_ttypes
                elif cur_ttypes != ttypes:
                    # we found an ambiguous relation: remove it from fetchattrs
                    fetchattrs.remove(attr)
                    # ... and add it to the selection
                    targetvar = select.make_variable()
                    select.add_selected(targetvar)
                    rel = make_relation(var, attr, (targetvar,), VariableRef)
                    select.add_restriction(rel)
                    break

Adrien Di Mascio's avatar
Adrien Di Mascio committed
313
    @classmethod
314
315
    def _fetch_restrictions(cls, mainvar, select, fetchattrs,
                            user, ordermethod='fetch_order', visited=None):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
316
317
318
319
320
321
322
323
        eschema = cls.e_schema
        if visited is None:
            visited = set((eschema.type,))
        elif eschema.type in visited:
            # avoid infinite recursion
            return
        else:
            visited.add(eschema.type)
324
        for attr in sorted(fetchattrs):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
325
            try:
326
                rschema = eschema.subjrels[attr]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
327
328
            except KeyError:
                cls.warning('skipping fetch_attr %s defined in %s (not found in schema)',
329
                            attr, cls.__regid__)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
330
                continue
331
332
            # XXX takefirst=True to remove warning triggered by ambiguous inlined relations
            rdef = eschema.rdef(attr, takefirst=True)
Sylvain Thénault's avatar
Sylvain Thénault committed
333
            if not user.matching_groups(rdef.get_groups('read')):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
334
                continue
335
336
337
338
339
340
341
342
343
            if rschema.final or rdef.cardinality[0] in '?1':
                var = select.make_variable()
                select.add_selected(var)
                rel = make_relation(mainvar, attr, (var,), VariableRef)
                select.add_restriction(rel)
            else:
                cls.warning('bad relation %s specified in fetch attrs for %s',
                            attr, cls)
                continue
344
            if not rschema.final:
345
346
347
348
                # XXX we need outer join in case the relation is not mandatory
                # (card == '?')  *or if the entity is being added*, since in
                # that case the relation may still be missing. As we miss this
                # later information here, systematically add it.
349
                rel.change_optional('right')
350
                targettypes = rschema.objects(eschema.type)
351
                vreg = user._cw.vreg  # XXX user._cw.vreg iiiirk
352
                etypecls = vreg['etypes'].etype_class(targettypes[0])
353
354
                if len(targettypes) > 1:
                    # find fetch_attrs common to all destination types
355
                    fetchattrs = vreg['etypes'].fetch_attrs(targettypes)
Sylvain Thénault's avatar
Sylvain Thénault committed
356
                    # ... and handle ambiguous relations
357
358
                    cls._fetch_ambiguous_rtypes(select, var, fetchattrs,
                                                targettypes, vreg.schema)
359
360
                else:
                    fetchattrs = etypecls.fetch_attrs
361
                etypecls._fetch_restrictions(var, select, fetchattrs,
362
                                             user, None, visited=visited)
363
            if ordermethod is not None:
Denis Laxalde's avatar
Denis Laxalde committed
364
365
                cmeth = getattr(cls, 'cw_' + ordermethod)
                cmeth(select, attr, var)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
366

367
368
    @classmethod
    @cached
369
370
371
372
373
374
375
376
377
378
379
    def cw_rest_attr_info(cls):
        """this class method return an attribute name to be used in URL for
        entities of this type and a boolean flag telling if its value should be
        checked for uniqness.

        The attribute returned is, in order of priority:

        * class's `rest_attr` class attribute
        * an attribute defined as unique in the class'schema
        * 'eid'
        """
380
381
382
383
384
385
        mainattr, needcheck = 'eid', True
        if cls.rest_attr:
            mainattr = cls.rest_attr
            needcheck = not cls.e_schema.has_unique_values(mainattr)
        else:
            for rschema in cls.e_schema.subject_relations():
386
                if (rschema.final
387
                    and rschema not in ('eid', 'cwuri')
388
389
                    and cls.e_schema.has_unique_values(rschema)
                    and cls.e_schema.rdef(rschema.type).cardinality[0] == '1'):
390
391
392
393
394
395
396
                    mainattr = str(rschema)
                    needcheck = False
                    break
        if mainattr == 'eid':
            needcheck = False
        return mainattr, needcheck

397
    @classmethod
398
    def _cw_build_entity_query(cls, kwargs):
399
400
        relations = []
        restrictions = set()
401
        pendingrels = []
402
        eschema = cls.e_schema
403
        qargs = {}
404
        attrcache = {}
405
        for attr, value in kwargs.items():
406
407
408
409
410
            if attr.startswith('reverse_'):
                attr = attr[len('reverse_'):]
                role = 'object'
            else:
                role = 'subject'
411
            assert eschema.has_relation(attr, role), '%s %s not found on %s' % (attr, role, eschema)
412
            rschema = eschema.subjrels[attr] if role == 'subject' else eschema.objrels[attr]
413
            if not rschema.final and isinstance(value, (tuple, list, set, frozenset)):
414
415
416
                if len(value) == 0:
                    continue # avoid crash with empty IN clause
                elif len(value) == 1:
417
                    value = next(iter(value))
418
                else:
419
                    # prepare IN clause
420
                    pendingrels.append( (attr, role, value) )
421
                    continue
422
423
            if rschema.final: # attribute
                relations.append('X %s %%(%s)s' % (attr, attr))
424
425
                attrcache[attr] = value
            elif value is None:
426
                pendingrels.append( (attr, role, value) )
427
            else:
428
                rvar = attr.upper()
429
430
                if role == 'object':
                    relations.append('%s %s X' % (rvar, attr))
431
432
433
434
435
                else:
                    relations.append('X %s %s' % (attr, rvar))
                restriction = '%s eid %%(%s)s' % (rvar, attr)
                if not restriction in restrictions:
                    restrictions.add(restriction)
436
437
438
                if hasattr(value, 'eid'):
                    value = value.eid
            qargs[attr] = value
439
        rql = u''
440
        if relations:
441
            rql += ', '.join(relations)
442
        if restrictions:
443
            rql += ' WHERE %s' % ', '.join(restrictions)
444
        return rql, qargs, pendingrels, attrcache
445
446

    @classmethod
447
448
    def _cw_handle_pending_relations(cls, eid, pendingrels, execute):
        for attr, role, values in pendingrels:
449
450
            if role == 'object':
                restr = 'Y %s X' % attr
451
452
            else:
                restr = 'X %s Y' % attr
453
454
455
            if values is None:
                execute('DELETE %s WHERE X eid %%(x)s' % restr, {'x': eid})
                continue
456
            execute('SET %s WHERE X eid %%(x)s, Y eid IN (%s)' % (
457
                restr, ','.join(str(getattr(r, 'eid', r)) for r in values)),
458
459
460
461
462
463
464
465
                    {'x': eid}, build_descr=False)

    @classmethod
    def cw_instantiate(cls, execute, **kwargs):
        """add a new entity of this given type

        Example (in a shell session):

Jérôme Roy's avatar
Jérôme Roy committed
466
467
        >>> companycls = vreg['etypes'].etype_class('Company')
        >>> personcls = vreg['etypes'].etype_class('Person')
468
469
470
471
472
473
474
475
        >>> c = companycls.cw_instantiate(session.execute, name=u'Logilab')
        >>> p = personcls.cw_instantiate(session.execute, firstname=u'John', lastname=u'Doe',
        ...                              works_for=c)

        You can also set relations where the entity has 'object' role by
        prefixing the relation name by 'reverse_'. Also, relation values may be
        an entity or eid, a list of entities or eids.
        """
476
        rql, qargs, pendingrels, attrcache = cls._cw_build_entity_query(kwargs)
477
478
479
480
        if rql:
            rql = 'INSERT %s X: %s' % (cls.__regid__, rql)
        else:
            rql = 'INSERT %s X' % (cls.__regid__)
481
482
483
484
485
        try:
            created = execute(rql, qargs).get_entity(0, 0)
        except IndexError:
            raise Exception('could not create a %r with %r (%r)' %
                            (cls.__regid__, rql, qargs))
486
        created._cw_update_attr_cache(attrcache)
487
        cls._cw_handle_pending_relations(created.eid, pendingrels, execute)
488
489
        return created

490
    def __init__(self, req, rset=None, row=None, col=0):
491
        AppObject.__init__(self, req, rset=rset, row=row, col=col)
492
        self._cw_related_cache = {}
493
        self._cw_adapters_cache = {}
Adrien Di Mascio's avatar
Adrien Di Mascio committed
494
495
496
497
        if rset is not None:
            self.eid = rset[row][col]
        else:
            self.eid = None
498
        self._cw_is_saved = True
499
        self.cw_attr_cache = {}
500

Adrien Di Mascio's avatar
Adrien Di Mascio committed
501
502
    def __repr__(self):
        return '<Entity %s %s %s at %s>' % (
503
            self.e_schema, self.eid, list(self.cw_attr_cache), id(self))
Adrien Di Mascio's avatar
Adrien Di Mascio committed
504

505
    def __lt__(self, other):
506
        return NotImplemented
507
508

    def __eq__(self, other):
Rémi Cardona's avatar
Rémi Cardona committed
509
        if isinstance(self.eid, integer_types):
510
511
512
513
            return self.eid == other.eid
        return self is other

    def __hash__(self):
Rémi Cardona's avatar
Rémi Cardona committed
514
        if isinstance(self.eid, integer_types):
515
516
            return self.eid
        return super(Entity, self).__hash__()
517

518
519
520
    def _cw_update_attr_cache(self, attrcache):
        trdata = self._cw.transaction_data
        uncached_attrs = trdata.get('%s.storage-special-process-attrs' % self.eid, set())
521
        uncached_attrs.update(trdata.get('%s.dont-cache-attrs' % self.eid, set()))
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
        for attr in uncached_attrs:
            attrcache.pop(attr, None)
            self.cw_attr_cache.pop(attr, None)
        self.cw_attr_cache.update(attrcache)

    def _cw_dont_cache_attribute(self, attr, repo_side=False):
        """Called when some attribute has been transformed by a *storage*,
        hence the original value should not be cached **by anyone**.

        For example we have a special "fs_importing" mode in BFSS
        where a file path is given as attribute value and stored as is
        in the data base. Later access to the attribute will provide
        the content of the file at the specified path. We do not want
        the "filepath" value to be cached.

        """
        trdata = self._cw.transaction_data
539
540
541
        trdata.setdefault('%s.dont-cache-attrs' % self.eid, set()).add(attr)
        if repo_side:
            trdata.setdefault('%s.storage-special-process-attrs' % self.eid, set()).add(attr)
542

543
544
545
546
    def __json_encode__(self):
        """custom json dumps hook to dump the entity's eid
        which is not part of dict structure itself
        """
547
        dumpable = self.cw_attr_cache.copy()
548
549
550
        dumpable['eid'] = self.eid
        return dumpable

551
552
553
554
555
    def cw_adapt_to(self, interface):
        """return an adapter the entity to the given interface name.

        return None if it can not be adapted.
        """
556
        cache = self._cw_adapters_cache
557
558
559
560
561
562
563
564
        try:
            return cache[interface]
        except KeyError:
            adapter = self._cw.vreg['adapters'].select_or_none(
                interface, self._cw, entity=self)
            cache[interface] = adapter
            return adapter

565
    def has_eid(self): # XXX cw_has_eid
Adrien Di Mascio's avatar
Adrien Di Mascio committed
566
567
568
569
        """return True if the entity has an attributed eid (False
        meaning that the entity has to be created
        """
        try:
570
            int(self.eid)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
571
572
573
574
            return True
        except (ValueError, TypeError):
            return False

575
    def cw_is_saved(self):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
576
        """during entity creation, there is some time during which the entity
577
578
579
        has an eid attributed though it's not saved (eg during
        'before_add_entity' hooks). You can use this method to ensure the entity
        has an eid *and* is saved in its source.
Adrien Di Mascio's avatar
Adrien Di Mascio committed
580
        """
581
        return self.has_eid() and self._cw_is_saved
582

583
    def cw_check_perm(self, action):
584
        self.e_schema.check_perm(self._cw, action, eid=self.eid)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
585

586
    def cw_has_perm(self, action):
587
        return self.e_schema.has_perm(self._cw, action, eid=self.eid)
588

589
    def view(self, __vid, __registry='views', w=None, initargs=None, **kwargs): # XXX cw_view
Adrien Di Mascio's avatar
Adrien Di Mascio committed
590
        """shortcut to apply a view on this entity"""
591
592
593
594
        if initargs is None:
            initargs = kwargs
        else:
            initargs.update(kwargs)
595
        view = self._cw.vreg[__registry].select(__vid, self._cw, rset=self.cw_rset,
596
                                                row=self.cw_row, col=self.cw_col,
597
                                                **initargs)
598
        return view.render(row=self.cw_row, col=self.cw_col, w=w, **kwargs)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
599

600
    def absolute_url(self, *args, **kwargs): # XXX cw_url
Adrien Di Mascio's avatar
Adrien Di Mascio committed
601
        """return an absolute url to view this entity"""
602
603
604
605
606
607
608
        # use *args since we don't want first argument to be "anonymous" to
        # avoid potential clash with kwargs
        if args:
            assert len(args) == 1, 'only 0 or 1 non-named-argument expected'
            method = args[0]
        else:
            method = None
609
        if method in (None, 'view'):
610
            kwargs['_restpath'] = self.rest_path()
Adrien Di Mascio's avatar
Adrien Di Mascio committed
611
612
        else:
            kwargs['rql'] = 'Any X WHERE X eid %s' % self.eid
613
        return self._cw.build_url(method, **kwargs)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
614

Denis Laxalde's avatar
Denis Laxalde committed
615
    def rest_path(self): # XXX cw_rest_path
Adrien Di Mascio's avatar
Adrien Di Mascio committed
616
        """returns a REST-like (relative) path for this entity"""
617
        mainattr, needcheck = self.cw_rest_attr_info()
Adrien Di Mascio's avatar
Adrien Di Mascio committed
618
        etype = str(self.e_schema)
619
        path = etype.lower()
620
        fallback = False
621
        if mainattr != 'eid':
622
            value = getattr(self, mainattr)
623
            if not can_use_rest_path(value):
624
                mainattr = 'eid'
625
                path = None
626
627
            elif needcheck:
                # make sure url is not ambiguous
628
629
630
631
632
633
                try:
                    nbresults = self.__unique
                except AttributeError:
                    rql = 'Any COUNT(X) WHERE X is %s, X %s %%(value)s' % (
                        etype, mainattr)
                    nbresults = self.__unique = self._cw.execute(rql, {'value' : value})[0][0]
634
635
                if nbresults != 1: # ambiguity?
                    mainattr = 'eid'
636
                    path = None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
637
        if mainattr == 'eid':
638
            value = self.eid
639
640
641
        if path is None:
            # fallback url: <base-url>/<eid> url is used as cw entities uri,
            # prefer it to <base-url>/<etype>/eid/<eid>
642
643
            return text_type(value)
        return u'%s/%s' % (path, self._cw.url_quote(value))
Adrien Di Mascio's avatar
Adrien Di Mascio committed
644

645
    def cw_attr_metadata(self, attr, metadata):
646
        """return a metadata for an attribute (None if unspecified)"""
647
        value = getattr(self, '%s_%s' % (attr, metadata), None)
648
        if value is None and metadata == 'encoding':
649
            value = self._cw.vreg.property_value('ui.encoding')
650
        return value
Adrien Di Mascio's avatar
Adrien Di Mascio committed
651
652

    def printable_value(self, attr, value=_marker, attrtype=None,
653
                        format='text/html', displaytime=True): # XXX cw_printable_value
Adrien Di Mascio's avatar
Adrien Di Mascio committed
654
655
656
657
658
        """return a displayable value (i.e. unicode string) which may contains
        html tags
        """
        attr = str(attr)
        if value is _marker:
659
            value = getattr(self, attr)
660
        if isinstance(value, string_types):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
661
662
663
664
665
            value = value.strip()
        if value is None or value == '': # don't use "not", 0 is an acceptable value
            return u''
        if attrtype is None:
            attrtype = self.e_schema.destination(attr)
Sylvain Thénault's avatar
Sylvain Thénault committed
666
        props = self.e_schema.rdef(attr)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
667
668
669
        if attrtype == 'String':
            # internalinalized *and* formatted string such as schema
            # description...
Sylvain Thénault's avatar
Sylvain Thénault committed
670
            if props.internationalizable:
671
                value = self._cw._(value)
672
            attrformat = self.cw_attr_metadata(attr, 'format')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
673
            if attrformat:
674
675
                return self._cw_mtc_transform(value, attrformat, format,
                                              self._cw.encoding)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
676
        elif attrtype == 'Bytes':
677
            attrformat = self.cw_attr_metadata(attr, 'format')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
678
            if attrformat:
679
680
681
                encoding = self.cw_attr_metadata(attr, 'encoding')
                return self._cw_mtc_transform(value.getvalue(), attrformat, format,
                                              encoding)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
682
            return u''
683
684
        value = self._cw.printable_value(attrtype, value, props,
                                         displaytime=displaytime)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
685
        if format == 'text/html':
Sylvain Thénault's avatar
Sylvain Thénault committed
686
            value = xml_escape(value)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
687
688
        return value

689
690
    def _cw_mtc_transform(self, data, format, target_format, encoding,
                          _engine=ENGINE):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
691
692
        trdata = TransformData(data, format, encoding, appobject=self)
        data = _engine.convert(trdata, target_format).decode()
693
        if target_format == 'text/html':
694
            data = soup2xhtml(data, self._cw.encoding)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
695
        return data
696

Adrien Di Mascio's avatar
Adrien Di Mascio committed
697
698
    # entity cloning ##########################################################

699
    def copy_relations(self, ceid): # XXX cw_copy_relations
Alexandre Fayolle's avatar
Alexandre Fayolle committed
700
701
702
        """copy relations of the object with the given eid on this
        object (this method is called on the newly created copy, and
        ceid designates the original entity).
Adrien Di Mascio's avatar
Adrien Di Mascio committed
703
704
705
706
707

        By default meta and composite relations are skipped.
        Overrides this if you want another behaviour
        """
        assert self.has_eid()
708
        execute = self._cw.execute
709
710
711
712
        skip_copy_for = {'subject': set(), 'object': set()}
        for rtype, role in self.cw_skip_copy_for:
            assert role in ('subject', 'object'), role
            skip_copy_for[role].add(rtype)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
713
        for rschema in self.e_schema.subject_relations():
714
715
            if rschema.type in skip_copy_for['subject']:
                continue
716
            if rschema.final or rschema.meta or rschema.rule:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
717
                continue
718
719
            # skip already defined relations
            if getattr(self, rschema.type):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
720
                continue
721
722
            # XXX takefirst=True to remove warning triggered by ambiguous relations
            rdef = self.e_schema.rdef(rschema, takefirst=True)
723
            # skip composite relation
Sylvain Thénault's avatar
Sylvain Thénault committed
724
            if rdef.composite:
725
726
727
                continue
            # skip relation with card in ?1 else we either change the copied
            # object (inlined relation) or inserting some inconsistency
Sylvain Thénault's avatar
Sylvain Thénault committed
728
            if rdef.cardinality[1] in '?1':
729
                continue
Adrien Di Mascio's avatar
Adrien Di Mascio committed
730
            rql = 'SET X %s V WHERE X eid %%(x)s, Y eid %%(y)s, Y %s V' % (
731
                rschema.type, rschema.type)
732
            execute(rql, {'x': self.eid, 'y': ceid})
733
            self.cw_clear_relation_cache(rschema.type, 'subject')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
734
        for rschema in self.e_schema.object_relations():
735
            if rschema.meta or rschema.rule:
736
737
                continue
            # skip already defined relations
Sylvain Thénault's avatar
Sylvain Thénault committed
738
            if self.related(rschema.type, 'object'):
739
                continue
740
741
            if rschema.type in skip_copy_for['object']:
                continue
742
743
            # XXX takefirst=True to remove warning triggered by ambiguous relations
            rdef = self.e_schema.rdef(rschema, 'object', takefirst=True)
744
            # skip composite relation
Sylvain Thénault's avatar
Sylvain Thénault committed
745
            if rdef.composite:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
746
747
748
                continue
            # skip relation with card in ?1 else we either change the copied
            # object (inlined relation) or inserting some inconsistency
Sylvain Thénault's avatar
Sylvain Thénault committed
749
            if rdef.cardinality[0] in '?1':
Adrien Di Mascio's avatar
Adrien Di Mascio committed
750
751
                continue
            rql = 'SET V %s X WHERE X eid %%(x)s, Y eid %%(y)s, V %s Y' % (
752
                rschema.type, rschema.type)
753
            execute(rql, {'x': self.eid, 'y': ceid})
754
            self.cw_clear_relation_cache(rschema.type, 'object')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
755
756
757

    # data fetching methods ###################################################

758
    @cached
759
    def as_rset(self): # XXX .cw_as_rset
Adrien Di Mascio's avatar
Adrien Di Mascio committed
760
761
        """returns a resultset containing `self` information"""
        rset = ResultSet([(self.eid,)], 'Any X WHERE X eid %(x)s',
762
                         {'x': self.eid}, [(self.cw_etype,)])
763
764
        rset.req = self._cw
        return rset
765

766
    def _cw_to_complete_relations(self):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
767
768
        """by default complete final relations to when calling .complete()"""
        for rschema in self.e_schema.subject_relations():
769
            if rschema.final:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
770
                continue
Sylvain Thénault's avatar
Sylvain Thénault committed
771
            targets = rschema.objects(self.e_schema)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
772
            if rschema.inlined:
773
                matching_groups = self._cw.user.matching_groups
774
775
776
                if all(matching_groups(e.get_groups('read')) and
                       rschema.rdef(self.e_schema, e).get_groups('read')
                       for e in targets):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
777
                    yield rschema, 'subject'
778

779
    def _cw_to_complete_attributes(self, skip_bytes=True, skip_pwd=True):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
780
781
782
783
784
785
786
        for rschema, attrschema in self.e_schema.attribute_definitions():
            # skip binary data by default
            if skip_bytes and attrschema.type == 'Bytes':
                continue
            attr = rschema.type
            if attr == 'eid':
                continue
Julien Cristau's avatar
Julien Cristau committed
787
            # password retrieval is blocked at the repository server level
Sylvain Thénault's avatar
Sylvain Thénault committed
788
            rdef = rschema.rdef(self.e_schema, attrschema)
789
            if not self._cw.user.matching_groups(rdef.get_groups('read')) \
790
                   or (attrschema.type == 'Password' and skip_pwd):
791
                self.cw_attr_cache[attr] = None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
792
793
                continue
            yield attr
794

795
    _cw_completed = False
796
    def complete(self, attributes=None, skip_bytes=True, skip_pwd=True): # XXX cw_complete
Adrien Di Mascio's avatar
Adrien Di Mascio committed
797
798
799
800
801
802
803
804
        """complete this entity by adding missing attributes (i.e. query the
        repository to fill the entity)

        :type skip_bytes: bool
        :param skip_bytes:
          if true, attribute of type Bytes won't be considered
        """
        assert self.has_eid()
805
806
807
808
        if self._cw_completed:
            return
        if attributes is None:
            self._cw_completed = True
Adrien Di Mascio's avatar
Adrien Di Mascio committed
809
        varmaker = rqlvar_maker()
810
        V = next(varmaker)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
811
812
        rql = ['WHERE %s eid %%(x)s' % V]
        selected = []
813
        for attr in (attributes or self._cw_to_complete_attributes(skip_bytes, skip_pwd)):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
814
            # if attribute already in entity, nothing to do
815
            if attr in self.cw_attr_cache:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
816
817
                continue
            # case where attribute must be completed, but is not yet in entity
818
            var = next(varmaker)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
819
820
            rql.append('%s %s %s' % (V, attr, var))
            selected.append((attr, var))
821
        # +1 since this doesn't include the main variable
Adrien Di Mascio's avatar
Adrien Di Mascio committed
822
        lastattr = len(selected) + 1
823
        if attributes is None:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
824
            # fetch additional relations (restricted to 0..1 relations)
825
            for rschema, role in self._cw_to_complete_relations():
Adrien Di Mascio's avatar
Adrien Di Mascio committed
826
                rtype = rschema.type
827
                if self.cw_relation_cached(rtype, role):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
828
                    continue
829
830
831
832
833
834
                # at this point we suppose that:
                # * this is a inlined relation
                # * entity (self) is the subject
                # * user has read perm on the relation and on the target entity
                assert rschema.inlined
                assert role == 'subject'
835
                var = next(varmaker)
836
837
838
                # keep outer join anyway, we don't want .complete to crash on
                # missing mandatory relation (see #1058267)
                rql.append('%s %s %s?' % (V, rtype, var))
Adrien Di Mascio's avatar
Adrien Di Mascio committed
839
840
841
842
843
844
                selected.append(((rtype, role), var))
        if selected:
            # select V, we need it as the left most selected variable
            # if some outer join are included to fetch inlined relations
            rql = 'Any %s,%s %s' % (V, ','.join(var for attr, var in selected),
                                    ','.join(rql))
845
846
847
848
849
            try:
                rset = self._cw.execute(rql, {'x': self.eid}, build_descr=False)[0]
            except IndexError:
                raise Exception('unable to fetch attributes for entity with eid %s'
                                % self.eid)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
850
            # handle attributes
851
            for i in range(1, lastattr):
852
                self.cw_attr_cache[str(selected[i-1][0])] = rset[i]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
853
            # handle relations
854
            for i in range(lastattr, len(rset)):
855
                rtype, role = selected[i-1][0]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
856
857
858
                value = rset[i]
                if value is None:
                    rrset = ResultSet([], rql, {'x': self.eid})
859
                    rrset.req = self._cw
Adrien Di Mascio's avatar
Adrien Di Mascio committed
860
                else:
861
                    rrset = self._cw.eid_rset(value)
862
                self.cw_set_relation_cache(rtype, role, rrset)
863

864
    def cw_attr_value(self, name):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
865
866
867
868
869
870
871
        """get value for the attribute relation <name>, query the repository
        to get the value if necessary.

        :type name: str
        :param name: name of the attribute to get
        """
        try:
872
            return self.cw_attr_cache[name]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
873
        except KeyError:
874
            if not self.cw_is_saved():
Adrien Di Mascio's avatar
Adrien Di Mascio committed
875
876
877
                return None
            rql = "Any A WHERE X eid %%(x)s, X %s A" % name
            try:
878
                rset = self._cw.execute(rql, {'x': self.eid})
Adrien Di Mascio's avatar
Adrien Di Mascio committed
879
            except Unauthorized:
880
                self.cw_attr_cache[name] = value = None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
881
882
883
            else:
                assert rset.rowcount <= 1, (self, rql, rset.rowcount)
                try:
884
                    self.cw_attr_cache[name] = value = rset.rows[0][0]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
885
886
887
888
889
                except IndexError:
                    # probably a multisource error
                    self.critical("can't get value for attribute %s of entity with eid %s",
                                  name, self.eid)
                    if self.e_schema.destination(name) == 'String':
890
                        self.cw_attr_cache[name] = value = self._cw._('unaccessible')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
891
                    else:
892
893
                        self.cw_attr_cache[name] = value = None
            return value
Adrien Di Mascio's avatar
Adrien Di Mascio committed
894

895
    def related(self, rtype, role='subject', limit=None, entities=False, # XXX .cw_related
896
                safe=False, targettypes=None):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
897
        """returns a resultset of related entities
898

899
900
901
902
903
904
905
906
907
908
909
        :param rtype:
          the name of the relation, aka relation type
        :param role:
          the role played by 'self' in the relation ('subject' or 'object')
        :param limit:
          resultset's maximum size
        :param entities:
          if True, the entites are returned; if False, a result set is returned
        :param safe:
          if True, an empty rset/list of entities will be returned in case of
          :exc:`Unauthorized`, else (the default), the exception is propagated
910
911
        :param targettypes:
          a tuple of target entity types to restrict the query
Adrien Di Mascio's avatar
Adrien Di Mascio committed
912
        """
913
        rtype = str(rtype)
914
915
916
        # Caching restricted/limited results is best avoided.
        cacheable = limit is None and targettypes is None
        if cacheable:
917
918
919
            cache_key = '%s_%s' % (rtype, role)
            if cache_key in self._cw_related_cache:
                return self._cw_related_cache[cache_key][entities]
920
921
922
        if not self.has_eid():
            if entities:
                return []
923
            return self._cw.empty_rset()
924
        rql = self.cw_related_rql(rtype, role, limit=limit, targettypes=targettypes)
925
926
927
928
929
930
        try:
            rset = self._cw.execute(rql, {'x': self.eid})
        except Unauthorized:
            if not safe:
                raise
            rset = self._cw.empty_rset()
931
932
        if cacheable:
            self.cw_set_relation_cache(rtype, role, rset)
933
        if entities:
934
            return tuple(rset.entities())
935
936
        else:
            return rset
Adrien Di Mascio's avatar
Adrien Di Mascio committed
937

938
    def cw_related_rql(self, rtype, role='subject', targettypes=None, limit=None):
939
940
941
        return self.cw_related_rqlst(
            rtype, role=role, targettypes=targettypes, limit=limit).as_string()

942
943
944
945
946
947
948
949
950
951
952
953
954
    def cw_related_rqlst(self, rtype, role='subject', targettypes=None,
                         limit=None, sort_terms=None):
        """Return the select node of the RQL query of entities related through
        `rtype` with this entity as `role`, possibly filtered by
        `targettypes`.

        The RQL query can be given a `limit` and sort terms with `sort_terms`
        arguments being a sequence of ``(<relation type>, <sort ascending>)``
        (e.g. ``[('name', True), ('modification_date', False)]`` would lead to
        a sorting by ``name``, ascending and then by ``modification_date``,
        descending. If `sort_terms` is not specified the default sorting is by
        ``modification_date``, descending.
        """
955
956
        vreg = self._cw.vreg
        rschema = vreg.schema[rtype]
957
958
959
        select = Select()
        mainvar, evar = select.get_variable('X'), select.get_variable('E')
        select.add_selected(mainvar)
960
961
        if limit is not None:
            select.set_limit(limit)
962
        select.add_eid_restriction(evar, 'x', 'Substitute')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
963
        if role == 'subject':
964
965
            rel = make_relation(evar, rtype, (mainvar,), VariableRef)
            select.add_restriction(rel)
966
967
            if targettypes is None:
                targettypes = rschema.objects(self.e_schema)
968
            else:
969
970
971
                select.add_constant_restriction(mainvar, 'is',
                                                targettypes, 'etype')
            gcard = greater_card(rschema, (self.e_schema,), targettypes, 0)
Adrien Di Mascio's avatar <