entity.py 57 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 logilab.common.decorators import cached
21
from logilab.common.registry import yes
Pierre-Yves David's avatar
Pierre-Yves David committed
22
from logilab.mtconverter import TransformData, xml_escape
23

Adrien Di Mascio's avatar
Adrien Di Mascio committed
24
from rql.utils import rqlvar_maker
25
26
27
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
28

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

37
from cubicweb.uilib import soup2xhtml
Sylvain Thénault's avatar
Sylvain Thénault committed
38
from cubicweb.mttransforms import ENGINE
Adrien Di Mascio's avatar
Adrien Di Mascio committed
39
40
41

_marker = object()

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

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

51
52
53
54
55
56
57
58
59
60
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
    # 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

61
62
63
64
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
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
102
    for (info_rtype, info_role), eids in lt_infos.items():
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
        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 = {}
129
    for (lt_rtype, lt_role), eids in lt_infos.items():
130
131
132
133
134
135
136
137
138
139
        # 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
140

141

142
class Entity(AppObject):
143
144
    """an entity instance has e_schema automagically set on
    the class and instances has access to their issuing cursor.
145

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

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

154
155
156
157
    :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)
158

159
160
    :type cw_skip_copy_for: list
    :cvar cw_skip_copy_for: a list of couples (rtype, role) for each relation
161
162
163
       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
164
165
    """
    __registry__ = 'etypes'
166
    __select__ = yes()
167

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

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

195
    fetch_attrs = ('modification_date',)
196

197
    @classmethod
198
199
200
201
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
    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`
232
        """
233
        cls.cw_fetch_unrelated_order(select, attr, var)
234
235

    @classmethod
236
237
238
    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
239
        new relation on an edited entity).
240
241
242
243
244
245

        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.
246
247
        """
        if attr == 'modification_date':
248
            select.add_sort_var(var, asc=False)
249

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

    @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)
Denis Laxalde's avatar
Denis Laxalde committed
264
        elif isinstance(mainvar, str):
265
266
267
268
269
            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
270
        if settype:
271
272
273
274
            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
275
276
        if fetchattrs is None:
            fetchattrs = cls.fetch_attrs
277
278
        cls._fetch_restrictions(mainvar, select, fetchattrs, user, ordermethod)
        return select
279

280
281
282
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
    @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
309
    @classmethod
310
311
    def _fetch_restrictions(cls, mainvar, select, fetchattrs,
                            user, ordermethod='fetch_order', visited=None):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
312
313
314
315
316
317
318
319
        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)
320
        for attr in sorted(fetchattrs):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
321
            try:
322
                rschema = eschema.subjrels[attr]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
323
324
            except KeyError:
                cls.warning('skipping fetch_attr %s defined in %s (not found in schema)',
325
                            attr, cls.__regid__)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
326
                continue
327
328
            # 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
329
            if not user.matching_groups(rdef.get_groups('read')):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
330
                continue
331
332
333
334
335
336
337
338
339
            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
340
            if not rschema.final:
341
342
343
344
                # 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.
345
                rel.change_optional('right')
346
                targettypes = rschema.objects(eschema.type)
347
                vreg = user._cw.vreg  # XXX user._cw.vreg iiiirk
348
                etypecls = vreg['etypes'].etype_class(targettypes[0])
349
350
                if len(targettypes) > 1:
                    # find fetch_attrs common to all destination types
351
                    fetchattrs = vreg['etypes'].fetch_attrs(targettypes)
Sylvain Thénault's avatar
Sylvain Thénault committed
352
                    # ... and handle ambiguous relations
353
354
                    cls._fetch_ambiguous_rtypes(select, var, fetchattrs,
                                                targettypes, vreg.schema)
355
356
                else:
                    fetchattrs = etypecls.fetch_attrs
357
                etypecls._fetch_restrictions(var, select, fetchattrs,
358
                                             user, None, visited=visited)
359
            if ordermethod is not None:
Denis Laxalde's avatar
Denis Laxalde committed
360
361
                cmeth = getattr(cls, 'cw_' + ordermethod)
                cmeth(select, attr, var)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
362

363
364
    @classmethod
    @cached
365
366
367
368
369
370
371
372
373
374
375
    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'
        """
376
377
378
379
380
381
        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():
382
                if (rschema.final
383
                    and rschema not in ('eid', 'cwuri')
384
385
                    and cls.e_schema.has_unique_values(rschema)
                    and cls.e_schema.rdef(rschema.type).cardinality[0] == '1'):
386
387
388
389
390
391
392
                    mainattr = str(rschema)
                    needcheck = False
                    break
        if mainattr == 'eid':
            needcheck = False
        return mainattr, needcheck

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

    @classmethod
443
444
    def _cw_handle_pending_relations(cls, eid, pendingrels, execute):
        for attr, role, values in pendingrels:
445
446
            if role == 'object':
                restr = 'Y %s X' % attr
447
448
            else:
                restr = 'X %s Y' % attr
449
450
451
            if values is None:
                execute('DELETE %s WHERE X eid %%(x)s' % restr, {'x': eid})
                continue
452
            execute('SET %s WHERE X eid %%(x)s, Y eid IN (%s)' % (
453
                restr, ','.join(str(getattr(r, 'eid', r)) for r in values)),
454
455
456
457
458
459
460
461
                    {'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
462
463
        >>> companycls = vreg['etypes'].etype_class('Company')
        >>> personcls = vreg['etypes'].etype_class('Person')
464
465
466
467
468
469
470
471
        >>> 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.
        """
472
        rql, qargs, pendingrels, attrcache = cls._cw_build_entity_query(kwargs)
473
474
475
476
        if rql:
            rql = 'INSERT %s X: %s' % (cls.__regid__, rql)
        else:
            rql = 'INSERT %s X' % (cls.__regid__)
477
478
479
480
481
        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))
482
        created._cw_update_attr_cache(attrcache)
483
        cls._cw_handle_pending_relations(created.eid, pendingrels, execute)
484
485
        return created

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

Adrien Di Mascio's avatar
Adrien Di Mascio committed
497
498
    def __repr__(self):
        return '<Entity %s %s %s at %s>' % (
499
            self.e_schema, self.eid, list(self.cw_attr_cache), id(self))
Adrien Di Mascio's avatar
Adrien Di Mascio committed
500

501
    def __lt__(self, other):
502
        return NotImplemented
503
504

    def __eq__(self, other):
Denis Laxalde's avatar
Denis Laxalde committed
505
        if isinstance(self.eid, int):
506
507
508
509
            return self.eid == other.eid
        return self is other

    def __hash__(self):
Denis Laxalde's avatar
Denis Laxalde committed
510
        if isinstance(self.eid, int):
511
512
            return self.eid
        return super(Entity, self).__hash__()
513

514
515
516
    def _cw_update_attr_cache(self, attrcache):
        trdata = self._cw.transaction_data
        uncached_attrs = trdata.get('%s.storage-special-process-attrs' % self.eid, set())
517
        uncached_attrs.update(trdata.get('%s.dont-cache-attrs' % self.eid, set()))
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
        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
535
536
537
        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)
538

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

547
548
549
550
551
    def cw_adapt_to(self, interface):
        """return an adapter the entity to the given interface name.

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

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

571
    def cw_is_saved(self):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
572
        """during entity creation, there is some time during which the entity
573
574
575
        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
576
        """
577
        return self.has_eid() and self._cw_is_saved
578

579
    def cw_check_perm(self, action):
580
        self.e_schema.check_perm(self._cw, action, eid=self.eid)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
581

582
    def cw_has_perm(self, action):
583
        return self.e_schema.has_perm(self._cw, action, eid=self.eid)
584

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

596
    def absolute_url(self, *args, **kwargs): # XXX cw_url
Adrien Di Mascio's avatar
Adrien Di Mascio committed
597
        """return an absolute url to view this entity"""
598
599
600
601
602
603
604
        # 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
605
        if method in (None, 'view'):
606
            kwargs['_restpath'] = self.rest_path()
Adrien Di Mascio's avatar
Adrien Di Mascio committed
607
608
        else:
            kwargs['rql'] = 'Any X WHERE X eid %s' % self.eid
609
        return self._cw.build_url(method, **kwargs)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
610

Denis Laxalde's avatar
Denis Laxalde committed
611
    def rest_path(self): # XXX cw_rest_path
Adrien Di Mascio's avatar
Adrien Di Mascio committed
612
        """returns a REST-like (relative) path for this entity"""
613
        mainattr, needcheck = self.cw_rest_attr_info()
Adrien Di Mascio's avatar
Adrien Di Mascio committed
614
        etype = str(self.e_schema)
615
        path = etype.lower()
616
        fallback = False
617
        if mainattr != 'eid':
618
            value = getattr(self, mainattr)
619
            if not can_use_rest_path(value):
620
                mainattr = 'eid'
621
                path = None
622
623
            elif needcheck:
                # make sure url is not ambiguous
624
625
626
627
628
629
                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]
630
631
                if nbresults != 1: # ambiguity?
                    mainattr = 'eid'
632
                    path = None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
633
        if mainattr == 'eid':
634
            value = self.eid
635
636
637
        if path is None:
            # fallback url: <base-url>/<eid> url is used as cw entities uri,
            # prefer it to <base-url>/<etype>/eid/<eid>
Denis Laxalde's avatar
Denis Laxalde committed
638
            return str(value)
639
        return u'%s/%s' % (path, self._cw.url_quote(value))
Adrien Di Mascio's avatar
Adrien Di Mascio committed
640

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

    def printable_value(self, attr, value=_marker, attrtype=None,
649
                        format='text/html', displaytime=True): # XXX cw_printable_value
Adrien Di Mascio's avatar
Adrien Di Mascio committed
650
651
652
653
654
        """return a displayable value (i.e. unicode string) which may contains
        html tags
        """
        attr = str(attr)
        if value is _marker:
655
            value = getattr(self, attr)
Denis Laxalde's avatar
Denis Laxalde committed
656
        if isinstance(value, str):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
657
658
659
660
661
            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
662
        props = self.e_schema.rdef(attr)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
663
664
665
        if attrtype == 'String':
            # internalinalized *and* formatted string such as schema
            # description...
Sylvain Thénault's avatar
Sylvain Thénault committed
666
            if props.internationalizable:
667
                value = self._cw._(value)
668
            attrformat = self.cw_attr_metadata(attr, 'format')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
669
            if attrformat:
670
671
                return self._cw_mtc_transform(value, attrformat, format,
                                              self._cw.encoding)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
672
        elif attrtype == 'Bytes':
673
            attrformat = self.cw_attr_metadata(attr, 'format')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
674
            if attrformat:
675
676
677
                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
678
            return u''
679
680
        value = self._cw.printable_value(attrtype, value, props,
                                         displaytime=displaytime)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
681
        if format == 'text/html':
Sylvain Thénault's avatar
Sylvain Thénault committed
682
            value = xml_escape(value)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
683
684
        return value

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

Adrien Di Mascio's avatar
Adrien Di Mascio committed
693
694
    # entity cloning ##########################################################

695
    def copy_relations(self, ceid): # XXX cw_copy_relations
Alexandre Fayolle's avatar
Alexandre Fayolle committed
696
697
698
        """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
699
700
701
702
703

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

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

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

762
    def _cw_to_complete_relations(self):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
763
764
        """by default complete final relations to when calling .complete()"""
        for rschema in self.e_schema.subject_relations():
765
            if rschema.final:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
766
                continue
Sylvain Thénault's avatar
Sylvain Thénault committed
767
            targets = rschema.objects(self.e_schema)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
768
            if rschema.inlined:
769
                matching_groups = self._cw.user.matching_groups
770
771
772
                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
773
                    yield rschema, 'subject'
774

775
    def _cw_to_complete_attributes(self, skip_bytes=True, skip_pwd=True):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
776
777
778
779
780
781
782
        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
783
            # password retrieval is blocked at the repository server level
Sylvain Thénault's avatar
Sylvain Thénault committed
784
            rdef = rschema.rdef(self.e_schema, attrschema)
785
            if not self._cw.user.matching_groups(rdef.get_groups('read')) \
786
                   or (attrschema.type == 'Password' and skip_pwd):
787
                self.cw_attr_cache[attr] = None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
788
789
                continue
            yield attr
790

791
    _cw_completed = False
792
    def complete(self, attributes=None, skip_bytes=True, skip_pwd=True): # XXX cw_complete
Adrien Di Mascio's avatar
Adrien Di Mascio committed
793
794
795
796
797
798
799
800
        """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()
801
802
803
804
        if self._cw_completed:
            return
        if attributes is None:
            self._cw_completed = True
Adrien Di Mascio's avatar
Adrien Di Mascio committed
805
        varmaker = rqlvar_maker()
806
        V = next(varmaker)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
807
808
        rql = ['WHERE %s eid %%(x)s' % V]
        selected = []
809
        for attr in (attributes or self._cw_to_complete_attributes(skip_bytes, skip_pwd)):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
810
            # if attribute already in entity, nothing to do
811
            if attr in self.cw_attr_cache:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
812
813
                continue
            # case where attribute must be completed, but is not yet in entity
814
            var = next(varmaker)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
815
816
            rql.append('%s %s %s' % (V, attr, var))
            selected.append((attr, var))
817
        # +1 since this doesn't include the main variable
Adrien Di Mascio's avatar
Adrien Di Mascio committed
818
        lastattr = len(selected) + 1
819
        if attributes is None:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
820
            # fetch additional relations (restricted to 0..1 relations)
821
            for rschema, role in self._cw_to_complete_relations():
Adrien Di Mascio's avatar
Adrien Di Mascio committed
822
                rtype = rschema.type
823
                if self.cw_relation_cached(rtype, role):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
824
                    continue
825
826
827
828
829
830
                # 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'
831
                var = next(varmaker)
832
833
834
                # 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
835
836
837
838
839
840
                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))
841
842
843
844
845
            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
846
            # handle attributes
847
            for i in range(1, lastattr):
848
                self.cw_attr_cache[str(selected[i-1][0])] = rset[i]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
849
            # handle relations
850
            for i in range(lastattr, len(rset)):
851
                rtype, role = selected[i-1][0]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
852
853
854
                value = rset[i]
                if value is None:
                    rrset = ResultSet([], rql, {'x': self.eid})
855
                    rrset.req = self._cw
Adrien Di Mascio's avatar
Adrien Di Mascio committed
856
                else:
857
                    rrset = self._cw.eid_rset(value)
858
                self.cw_set_relation_cache(rtype, role, rrset)
859

860
    def cw_attr_value(self, name):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
861
862
863
864
865
866
867
        """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:
868
            return self.cw_attr_cache[name]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
869
        except KeyError:
870
            if not self.cw_is_saved():
Adrien Di Mascio's avatar
Adrien Di Mascio committed
871
872
873
                return None
            rql = "Any A WHERE X eid %%(x)s, X %s A" % name
            try:
874
                rset = self._cw.execute(rql, {'x': self.eid})
Adrien Di Mascio's avatar
Adrien Di Mascio committed
875
            except Unauthorized:
876
                self.cw_attr_cache[name] = value = None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
877
878
879
            else:
                assert rset.rowcount <= 1, (self, rql, rset.rowcount)
                try:
880
                    self.cw_attr_cache[name] = value = rset.rows[0][0]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
881
882
883
884
885
                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':
886
                        self.cw_attr_cache[name] = value = self._cw._('unaccessible')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
887
                    else:
888
889
                        self.cw_attr_cache[name] = value = None
            return value
Adrien Di Mascio's avatar
Adrien Di Mascio committed
890

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

895
896
897
898
899
900
901
902
903
904
905
        :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
906
907
        :param targettypes:
          a tuple of target entity types to restrict the query
Adrien Di Mascio's avatar
Adrien Di Mascio committed
908
        """
909
        rtype = str(rtype)
910
911
912
        # Caching restricted/limited results is best avoided.
        cacheable = limit is None and targettypes is None
        if cacheable:
913
914
915
            cache_key = '%s_%s' % (rtype, role)
            if cache_key in self._cw_related_cache:
                return self._cw_related_cache[cache_key][entities]
916
917
918
        if not self.has_eid():
            if entities:
                return []
919
            return self._cw.empty_rset()
920
        rql = self.cw_related_rql(rtype, role, limit=limit, targettypes=targettypes)
921
922
923
924
925
926
        try:
            rset = self._cw.execute(rql, {'x': self.eid})
        except Unauthorized:
            if not safe:
                raise
            rset = self._cw.empty_rset()
927
928
        if cacheable:
            self.cw_set_relation_cache(rtype, role, rset)
929
        if entities:
930
            return tuple(rset.entities())
931
932
        else:
            return rset
Adrien Di Mascio's avatar
Adrien Di Mascio committed
933

934
    def cw_related_rql(self, rtype, role='subject', targettypes=None, limit=None):
935
936
937
        return self.cw_related_rqlst(
            rtype, role=role, targettypes=targettypes, limit=limit).as_string()

938
939
940
941
942
943
944
945
946
947
948
949
950
    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.
        """
951
952
        vreg = self._cw.vreg
        rschema = vreg.schema[rtype]
953
954
955
        select = Select()
        mainvar, evar = select.get_variable('X'), select.get_variable('E')
        select.add_selected(mainvar)
956
957
        if limit is not None:
            select.set_limit(limit)
958
        select.add_eid_restriction(evar, 'x', 'Substitute')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
959
        if role == 'subject':
960
961
            rel = make_relation(evar, rtype, (mainvar,), VariableRef)
            select.add_restriction(rel)
962
963
            if targettypes is None:
                targettypes = rschema.objects(self.e_schema)
964
            else:
965
966
967
                select.add_constant_restriction(mainvar, 'is',
                                                targettypes, 'etype')
            gcard = greater_card(rschema, (self.e_schema,), targettypes, 0)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
968
        else:
969
970
            rel = make_relation(mainvar, rtype, (evar,), VariableRef)
            select.add_restriction(rel)
971
972
            if targettypes is None:
                targettypes = rschema.subjects(self.e_schema)
973
            else:
974