entity.py 60.1 KB
Newer Older
1
# copyright 2003-2014 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
21

__docformat__ = "restructuredtext en"

22
23
from warnings import warn

24
from six import text_type, string_types, integer_types
25
26
from six.moves import range

Adrien Di Mascio's avatar
Adrien Di Mascio committed
27
from logilab.common.decorators import cached
28
from logilab.common.deprecation import deprecated
29
from logilab.common.registry import yes
Pierre-Yves David's avatar
Pierre-Yves David committed
30
from logilab.mtconverter import TransformData, xml_escape
31

Adrien Di Mascio's avatar
Adrien Di Mascio committed
32
from rql.utils import rqlvar_maker
33
34
35
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
36

37
from cubicweb import Unauthorized, neg_role
38
from cubicweb.utils import support_args
Adrien Di Mascio's avatar
Adrien Di Mascio committed
39
from cubicweb.rset import ResultSet
40
from cubicweb.appobject import AppObject
41
42
from cubicweb.schema import (RQLVocabularyConstraint, RQLConstraint,
                             GeneratedConstraint)
43
from cubicweb.rqlrewrite import RQLRewriter
44

45
from cubicweb.uilib import soup2xhtml
Sylvain Thénault's avatar
Sylvain Thénault committed
46
from cubicweb.mttransforms import ENGINE
Adrien Di Mascio's avatar
Adrien Di Mascio committed
47
48
49
50
51
52

_marker = object()

def greater_card(rschema, subjtypes, objtypes, index):
    for subjtype in subjtypes:
        for objtype in objtypes:
Sylvain Thénault's avatar
Sylvain Thénault committed
53
            card = rschema.rdef(subjtype, objtype).cardinality[index]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
54
55
56
57
            if card in '+*':
                return card
    return '1'

58
59
60
61
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
62
    value = text_type(value)
63
64
65
66
67
68
    # 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

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
106
107
108
109
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
110
    for (info_rtype, info_role), eids in lt_infos.items():
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
        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 = {}
137
    for (lt_rtype, lt_role), eids in lt_infos.items():
138
139
140
141
142
143
144
145
146
147
        # 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
148

149

150
class Entity(AppObject):
151
152
    """an entity instance has e_schema automagically set on
    the class and instances has access to their issuing cursor.
153

154
155
156
157
    A property is set for each attribute and relation on each entity's type
    class. Becare that among attributes, 'eid' is *NEITHER* stored in the
    dict containment (which acts as a cache for other attributes dynamically
    fetched)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
158
159
160
161

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

162
163
164
165
    :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)
166

167
168
    :type cw_skip_copy_for: list
    :cvar cw_skip_copy_for: a list of couples (rtype, role) for each relation
169
170
171
       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
172
173
    """
    __registry__ = 'etypes'
174
    __select__ = yes()
175

176
    # class attributes that must be set in class definition
Adrien Di Mascio's avatar
Adrien Di Mascio committed
177
    rest_attr = None
178
    fetch_attrs = None
179
180
    skip_copy_for = () # bw compat (< 3.14), use cw_skip_copy_for instead
    cw_skip_copy_for = [('in_state', 'subject')]
181
182
    # class attributes set automatically at registration time
    e_schema = None
183

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

204
    fetch_attrs = ('modification_date',)
205

206
    @classmethod
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
236
237
238
239
240
    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`
241
        """
242
        cls.cw_fetch_unrelated_order(select, attr, var)
243
244

    @classmethod
245
246
247
    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
248
        new relation on an edited entity).
249
250
251
252
253
254

        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.
255
256
        """
        if attr == 'modification_date':
257
            select.add_sort_var(var, asc=False)
258

Adrien Di Mascio's avatar
Adrien Di Mascio committed
259
260
261
    @classmethod
    def fetch_rql(cls, user, restriction=None, fetchattrs=None, mainvar='X',
                  settype=True, ordermethod='fetch_order'):
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
        st = cls.fetch_rqlst(user, mainvar=mainvar, fetchattrs=fetchattrs,
                             settype=settype, ordermethod=ordermethod)
        rql = st.as_string()
        if restriction:
            # cannot use RQLRewriter API to insert 'X rtype %(x)s' restriction
            warn('[3.14] fetch_rql: use of `restriction` parameter is '
                 'deprecated, please use fetch_rqlst and supply a syntax'
                 'tree with your restriction instead', DeprecationWarning)
            insert = ' WHERE ' + ','.join(restriction)
            if ' WHERE ' in rql:
                select, where = rql.split(' WHERE ', 1)
                rql = select + insert + ',' + where
            else:
                rql += insert
        return rql

    @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)
285
        elif isinstance(mainvar, string_types):
286
287
288
289
290
            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
291
        if settype:
292
293
294
295
            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
296
297
        if fetchattrs is None:
            fetchattrs = cls.fetch_attrs
298
299
        cls._fetch_restrictions(mainvar, select, fetchattrs, user, ordermethod)
        return select
300

301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
    @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
330
    @classmethod
331
332
    def _fetch_restrictions(cls, mainvar, select, fetchattrs,
                            user, ordermethod='fetch_order', visited=None):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
333
334
335
336
337
338
339
340
341
        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)
        _fetchattrs = []
342
        for attr in sorted(fetchattrs):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
343
            try:
344
                rschema = eschema.subjrels[attr]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
345
346
            except KeyError:
                cls.warning('skipping fetch_attr %s defined in %s (not found in schema)',
347
                            attr, cls.__regid__)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
348
                continue
349
350
            # 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
351
            if not user.matching_groups(rdef.get_groups('read')):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
352
                continue
353
354
355
356
357
358
359
360
361
            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
362
            if not rschema.final:
363
364
365
366
                # 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.
367
                rel.change_optional('right')
368
                targettypes = rschema.objects(eschema.type)
369
370
                vreg = user._cw.vreg # XXX user._cw.vreg iiiirk
                etypecls = vreg['etypes'].etype_class(targettypes[0])
371
372
                if len(targettypes) > 1:
                    # find fetch_attrs common to all destination types
373
                    fetchattrs = vreg['etypes'].fetch_attrs(targettypes)
Sylvain Thénault's avatar
Sylvain Thénault committed
374
                    # ... and handle ambiguous relations
375
376
                    cls._fetch_ambiguous_rtypes(select, var, fetchattrs,
                                                targettypes, vreg.schema)
377
378
                else:
                    fetchattrs = etypecls.fetch_attrs
379
                etypecls._fetch_restrictions(var, select, fetchattrs,
380
                                             user, None, visited=visited)
381
            if ordermethod is not None:
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
                try:
                    cmeth = getattr(cls, ordermethod)
                    warn('[3.14] %s %s class method should be renamed to cw_%s'
                         % (cls.__regid__, ordermethod, ordermethod),
                         DeprecationWarning)
                except AttributeError:
                    cmeth = getattr(cls, 'cw_' + ordermethod)
                if support_args(cmeth, 'select'):
                    cmeth(select, attr, var)
                else:
                    warn('[3.14] %s should now take (select, attr, var) and '
                         'modify the syntax tree when desired instead of '
                         'returning something' % cmeth, DeprecationWarning)
                    orderterm = cmeth(attr, var.name)
                    if orderterm is not None:
                        try:
                            var, order = orderterm.split()
                        except ValueError:
                            if '(' in orderterm:
401
402
                                cls.error('ignore %s until %s is upgraded',
                                          orderterm, cmeth)
403
404
405
406
407
408
409
                                orderterm = None
                            elif not ' ' in orderterm.strip():
                                var = orderterm
                                order = 'ASC'
                        if orderterm is not None:
                            select.add_sort_var(select.get_variable(var),
                                                order=='ASC')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
410

411
412
    @classmethod
    @cached
413
414
415
416
417
418
419
420
421
422
423
    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'
        """
424
425
426
427
428
429
        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():
430
                if (rschema.final
431
                    and rschema not in ('eid', 'cwuri')
432
433
                    and cls.e_schema.has_unique_values(rschema)
                    and cls.e_schema.rdef(rschema.type).cardinality[0] == '1'):
434
435
436
437
438
439
440
                    mainattr = str(rschema)
                    needcheck = False
                    break
        if mainattr == 'eid':
            needcheck = False
        return mainattr, needcheck

441
    @classmethod
442
    def _cw_build_entity_query(cls, kwargs):
443
444
        relations = []
        restrictions = set()
445
        pendingrels = []
446
        eschema = cls.e_schema
447
        qargs = {}
448
        attrcache = {}
449
        for attr, value in kwargs.items():
450
451
452
453
454
            if attr.startswith('reverse_'):
                attr = attr[len('reverse_'):]
                role = 'object'
            else:
                role = 'subject'
455
            assert eschema.has_relation(attr, role), '%s %s not found on %s' % (attr, role, eschema)
456
            rschema = eschema.subjrels[attr] if role == 'subject' else eschema.objrels[attr]
457
            if not rschema.final and isinstance(value, (tuple, list, set, frozenset)):
458
459
460
                if len(value) == 0:
                    continue # avoid crash with empty IN clause
                elif len(value) == 1:
461
                    value = next(iter(value))
462
                else:
463
                    # prepare IN clause
464
                    pendingrels.append( (attr, role, value) )
465
                    continue
466
467
            if rschema.final: # attribute
                relations.append('X %s %%(%s)s' % (attr, attr))
468
469
                attrcache[attr] = value
            elif value is None:
470
                pendingrels.append( (attr, role, value) )
471
            else:
472
                rvar = attr.upper()
473
474
                if role == 'object':
                    relations.append('%s %s X' % (rvar, attr))
475
476
477
478
479
                else:
                    relations.append('X %s %s' % (attr, rvar))
                restriction = '%s eid %%(%s)s' % (rvar, attr)
                if not restriction in restrictions:
                    restrictions.add(restriction)
480
481
482
                if hasattr(value, 'eid'):
                    value = value.eid
            qargs[attr] = value
483
        rql = u''
484
        if relations:
485
            rql += ', '.join(relations)
486
        if restrictions:
487
            rql += ' WHERE %s' % ', '.join(restrictions)
488
        return rql, qargs, pendingrels, attrcache
489
490

    @classmethod
491
492
    def _cw_handle_pending_relations(cls, eid, pendingrels, execute):
        for attr, role, values in pendingrels:
493
494
            if role == 'object':
                restr = 'Y %s X' % attr
495
496
            else:
                restr = 'X %s Y' % attr
497
498
499
            if values is None:
                execute('DELETE %s WHERE X eid %%(x)s' % restr, {'x': eid})
                continue
500
            execute('SET %s WHERE X eid %%(x)s, Y eid IN (%s)' % (
501
                restr, ','.join(str(getattr(r, 'eid', r)) for r in values)),
502
503
504
505
506
507
508
509
                    {'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
510
511
        >>> companycls = vreg['etypes'].etype_class('Company')
        >>> personcls = vreg['etypes'].etype_class('Person')
512
513
514
515
516
517
518
519
        >>> 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.
        """
520
        rql, qargs, pendingrels, attrcache = cls._cw_build_entity_query(kwargs)
521
522
523
524
        if rql:
            rql = 'INSERT %s X: %s' % (cls.__regid__, rql)
        else:
            rql = 'INSERT %s X' % (cls.__regid__)
525
526
527
528
529
        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))
530
        created._cw_update_attr_cache(attrcache)
531
        cls._cw_handle_pending_relations(created.eid, pendingrels, execute)
532
533
        return created

534
    def __init__(self, req, rset=None, row=None, col=0):
535
        AppObject.__init__(self, req, rset=rset, row=row, col=col)
536
        self._cw_related_cache = {}
537
        self._cw_adapters_cache = {}
Adrien Di Mascio's avatar
Adrien Di Mascio committed
538
539
540
541
        if rset is not None:
            self.eid = rset[row][col]
        else:
            self.eid = None
542
        self._cw_is_saved = True
543
        self.cw_attr_cache = {}
544

Adrien Di Mascio's avatar
Adrien Di Mascio committed
545
546
    def __repr__(self):
        return '<Entity %s %s %s at %s>' % (
547
            self.e_schema, self.eid, list(self.cw_attr_cache), id(self))
Adrien Di Mascio's avatar
Adrien Di Mascio committed
548

549
    def __lt__(self, other):
550
        return NotImplemented
551
552

    def __eq__(self, other):
Rémi Cardona's avatar
Rémi Cardona committed
553
        if isinstance(self.eid, integer_types):
554
555
556
557
            return self.eid == other.eid
        return self is other

    def __hash__(self):
Rémi Cardona's avatar
Rémi Cardona committed
558
        if isinstance(self.eid, integer_types):
559
560
            return self.eid
        return super(Entity, self).__hash__()
561

562
563
564
    def _cw_update_attr_cache(self, attrcache):
        trdata = self._cw.transaction_data
        uncached_attrs = trdata.get('%s.storage-special-process-attrs' % self.eid, set())
565
        uncached_attrs.update(trdata.get('%s.dont-cache-attrs' % self.eid, set()))
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
        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
583
584
585
        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)
586

587
588
589
590
    def __json_encode__(self):
        """custom json dumps hook to dump the entity's eid
        which is not part of dict structure itself
        """
591
        dumpable = self.cw_attr_cache.copy()
592
593
594
        dumpable['eid'] = self.eid
        return dumpable

595
596
597
598
599
    def cw_adapt_to(self, interface):
        """return an adapter the entity to the given interface name.

        return None if it can not be adapted.
        """
600
        cache = self._cw_adapters_cache
601
602
603
604
605
606
607
608
        try:
            return cache[interface]
        except KeyError:
            adapter = self._cw.vreg['adapters'].select_or_none(
                interface, self._cw, entity=self)
            cache[interface] = adapter
            return adapter

609
    def has_eid(self): # XXX cw_has_eid
Adrien Di Mascio's avatar
Adrien Di Mascio committed
610
611
612
613
        """return True if the entity has an attributed eid (False
        meaning that the entity has to be created
        """
        try:
614
            int(self.eid)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
615
616
617
618
            return True
        except (ValueError, TypeError):
            return False

619
    def cw_is_saved(self):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
620
        """during entity creation, there is some time during which the entity
621
622
623
        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
624
        """
625
        return self.has_eid() and self._cw_is_saved
626

Adrien Di Mascio's avatar
Adrien Di Mascio committed
627
    @cached
628
    def cw_metainformation(self):
629
630
631
        metas = self._cw.entity_metas(self.eid)
        metas['source'] = self._cw.source_defs()[metas['source']]
        return metas
Adrien Di Mascio's avatar
Adrien Di Mascio committed
632

633
    def cw_check_perm(self, action):
634
        self.e_schema.check_perm(self._cw, action, eid=self.eid)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
635

636
    def cw_has_perm(self, action):
637
        return self.e_schema.has_perm(self._cw, action, eid=self.eid)
638

639
    def view(self, __vid, __registry='views', w=None, initargs=None, **kwargs): # XXX cw_view
Adrien Di Mascio's avatar
Adrien Di Mascio committed
640
        """shortcut to apply a view on this entity"""
641
642
643
644
        if initargs is None:
            initargs = kwargs
        else:
            initargs.update(kwargs)
645
        view = self._cw.vreg[__registry].select(__vid, self._cw, rset=self.cw_rset,
646
                                                row=self.cw_row, col=self.cw_col,
647
                                                **initargs)
648
        return view.render(row=self.cw_row, col=self.cw_col, w=w, **kwargs)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
649

650
    def absolute_url(self, *args, **kwargs): # XXX cw_url
Adrien Di Mascio's avatar
Adrien Di Mascio committed
651
        """return an absolute url to view this entity"""
652
653
654
655
656
657
658
        # 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
Adrien Di Mascio's avatar
Adrien Di Mascio committed
659
660
661
        # in linksearch mode, we don't want external urls else selecting
        # the object for use in the relation is tricky
        # XXX search_state is web specific
662
        use_ext_id = False
663
        if 'base_url' not in kwargs and \
664
               getattr(self._cw, 'search_state', ('normal',))[0] == 'normal':
665
666
667
668
669
            sourcemeta = self.cw_metainformation()['source']
            if sourcemeta.get('use-cwuri-as-url'):
                return self.cwuri # XXX consider kwargs?
            if sourcemeta.get('base-url'):
                kwargs['base_url'] = sourcemeta['base-url']
670
                use_ext_id = True
671
        if method in (None, 'view'):
672
            kwargs['_restpath'] = self.rest_path(use_ext_id)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
673
674
        else:
            kwargs['rql'] = 'Any X WHERE X eid %s' % self.eid
675
        return self._cw.build_url(method, **kwargs)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
676

677
    def rest_path(self, use_ext_eid=False): # XXX cw_rest_path
Adrien Di Mascio's avatar
Adrien Di Mascio committed
678
        """returns a REST-like (relative) path for this entity"""
679
        mainattr, needcheck = self.cw_rest_attr_info()
Adrien Di Mascio's avatar
Adrien Di Mascio committed
680
        etype = str(self.e_schema)
681
        path = etype.lower()
682
        fallback = False
683
        if mainattr != 'eid':
684
            value = getattr(self, mainattr)
685
            if not can_use_rest_path(value):
686
                mainattr = 'eid'
687
                path = None
688
689
            elif needcheck:
                # make sure url is not ambiguous
690
691
692
693
694
695
                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]
696
697
                if nbresults != 1: # ambiguity?
                    mainattr = 'eid'
698
                    path = None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
699
        if mainattr == 'eid':
700
            if use_ext_eid:
701
                value = self.cw_metainformation()['extid']
702
703
            else:
                value = self.eid
704
705
706
        if path is None:
            # fallback url: <base-url>/<eid> url is used as cw entities uri,
            # prefer it to <base-url>/<etype>/eid/<eid>
707
708
            return text_type(value)
        return u'%s/%s' % (path, self._cw.url_quote(value))
Adrien Di Mascio's avatar
Adrien Di Mascio committed
709

710
    def cw_attr_metadata(self, attr, metadata):
711
        """return a metadata for an attribute (None if unspecified)"""
712
        value = getattr(self, '%s_%s' % (attr, metadata), None)
713
        if value is None and metadata == 'encoding':
714
            value = self._cw.vreg.property_value('ui.encoding')
715
        return value
Adrien Di Mascio's avatar
Adrien Di Mascio committed
716
717

    def printable_value(self, attr, value=_marker, attrtype=None,
718
                        format='text/html', displaytime=True): # XXX cw_printable_value
Adrien Di Mascio's avatar
Adrien Di Mascio committed
719
720
721
722
723
        """return a displayable value (i.e. unicode string) which may contains
        html tags
        """
        attr = str(attr)
        if value is _marker:
724
            value = getattr(self, attr)
725
        if isinstance(value, string_types):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
726
727
728
729
730
            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
731
        props = self.e_schema.rdef(attr)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
732
733
734
        if attrtype == 'String':
            # internalinalized *and* formatted string such as schema
            # description...
Sylvain Thénault's avatar
Sylvain Thénault committed
735
            if props.internationalizable:
736
                value = self._cw._(value)
737
            attrformat = self.cw_attr_metadata(attr, 'format')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
738
            if attrformat:
739
740
                return self._cw_mtc_transform(value, attrformat, format,
                                              self._cw.encoding)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
741
        elif attrtype == 'Bytes':
742
            attrformat = self.cw_attr_metadata(attr, 'format')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
743
            if attrformat:
744
745
746
                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
747
            return u''
748
749
        value = self._cw.printable_value(attrtype, value, props,
                                         displaytime=displaytime)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
750
        if format == 'text/html':
Sylvain Thénault's avatar
Sylvain Thénault committed
751
            value = xml_escape(value)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
752
753
        return value

754
755
    def _cw_mtc_transform(self, data, format, target_format, encoding,
                          _engine=ENGINE):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
756
757
        trdata = TransformData(data, format, encoding, appobject=self)
        data = _engine.convert(trdata, target_format).decode()
758
        if target_format == 'text/html':
759
            data = soup2xhtml(data, self._cw.encoding)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
760
        return data
761

Adrien Di Mascio's avatar
Adrien Di Mascio committed
762
763
    # entity cloning ##########################################################

764
    def copy_relations(self, ceid): # XXX cw_copy_relations
Alexandre Fayolle's avatar
Alexandre Fayolle committed
765
766
767
        """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
768
769
770
771
772

        By default meta and composite relations are skipped.
        Overrides this if you want another behaviour
        """
        assert self.has_eid()
773
        execute = self._cw.execute
774
775
776
777
        skip_copy_for = {'subject': set(), 'object': set()}
        for rtype in self.skip_copy_for:
            skip_copy_for['subject'].add(rtype)
            warn('[3.14] skip_copy_for on entity classes (%s) is deprecated, '
778
                 'use cw_skip_for instead with list of couples (rtype, role)' % self.cw_etype,
779
780
781
782
                 DeprecationWarning)
        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
783
        for rschema in self.e_schema.subject_relations():
784
785
            if rschema.type in skip_copy_for['subject']:
                continue
786
            if rschema.final or rschema.meta:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
787
                continue
788
789
            # skip already defined relations
            if getattr(self, rschema.type):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
790
                continue
791
792
            # XXX takefirst=True to remove warning triggered by ambiguous relations
            rdef = self.e_schema.rdef(rschema, takefirst=True)
793
            # skip composite relation
Sylvain Thénault's avatar
Sylvain Thénault committed
794
            if rdef.composite:
795
796
797
                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
798
            if rdef.cardinality[1] in '?1':
799
                continue
Adrien Di Mascio's avatar
Adrien Di Mascio committed
800
            rql = 'SET X %s V WHERE X eid %%(x)s, Y eid %%(y)s, Y %s V' % (
801
                rschema.type, rschema.type)
802
            execute(rql, {'x': self.eid, 'y': ceid})
803
            self.cw_clear_relation_cache(rschema.type, 'subject')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
804
        for rschema in self.e_schema.object_relations():
805
806
807
            if rschema.meta:
                continue
            # skip already defined relations
Sylvain Thénault's avatar
Sylvain Thénault committed
808
            if self.related(rschema.type, 'object'):
809
                continue
810
811
            if rschema.type in skip_copy_for['object']:
                continue
812
813
            # XXX takefirst=True to remove warning triggered by ambiguous relations
            rdef = self.e_schema.rdef(rschema, 'object', takefirst=True)
814
            # skip composite relation
Sylvain Thénault's avatar
Sylvain Thénault committed
815
            if rdef.composite:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
816
817
818
                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
819
            if rdef.cardinality[0] in '?1':
Adrien Di Mascio's avatar
Adrien Di Mascio committed
820
821
                continue
            rql = 'SET V %s X WHERE X eid %%(x)s, Y eid %%(y)s, V %s Y' % (
822
                rschema.type, rschema.type)
823
            execute(rql, {'x': self.eid, 'y': ceid})
824
            self.cw_clear_relation_cache(rschema.type, 'object')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
825
826
827

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

828
    @cached
829
    def as_rset(self): # XXX .cw_as_rset
Adrien Di Mascio's avatar
Adrien Di Mascio committed
830
831
        """returns a resultset containing `self` information"""
        rset = ResultSet([(self.eid,)], 'Any X WHERE X eid %(x)s',
832
                         {'x': self.eid}, [(self.cw_etype,)])
833
834
        rset.req = self._cw
        return rset
835

836
    def _cw_to_complete_relations(self):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
837
838
        """by default complete final relations to when calling .complete()"""
        for rschema in self.e_schema.subject_relations():
839
            if rschema.final:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
840
                continue
Sylvain Thénault's avatar
Sylvain Thénault committed
841
            targets = rschema.objects(self.e_schema)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
842
            if rschema.inlined:
843
                matching_groups = self._cw.user.matching_groups
844
845
846
                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
847
                    yield rschema, 'subject'
848

849
    def _cw_to_complete_attributes(self, skip_bytes=True, skip_pwd=True):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
850
851
852
853
854
855
856
        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
857
            # password retrieval is blocked at the repository server level
Sylvain Thénault's avatar
Sylvain Thénault committed
858
            rdef = rschema.rdef(self.e_schema, attrschema)
859
            if not self._cw.user.matching_groups(rdef.get_groups('read')) \
860
                   or (attrschema.type == 'Password' and skip_pwd):
861
                self.cw_attr_cache[attr] = None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
862
863
                continue
            yield attr
864

865
    _cw_completed = False
866
    def complete(self, attributes=None, skip_bytes=True, skip_pwd=True): # XXX cw_complete
Adrien Di Mascio's avatar
Adrien Di Mascio committed
867
868
869
870
871
872
873
874
        """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()
875
876
877
878
        if self._cw_completed:
            return
        if attributes is None:
            self._cw_completed = True
Adrien Di Mascio's avatar
Adrien Di Mascio committed
879
        varmaker = rqlvar_maker()
880
        V = next(varmaker)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
881
882
        rql = ['WHERE %s eid %%(x)s' % V]
        selected = []
883
        for attr in (attributes or self._cw_to_complete_attributes(skip_bytes, skip_pwd)):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
884
            # if attribute already in entity, nothing to do
885
            if attr in self.cw_attr_cache:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
886
887
                continue
            # case where attribute must be completed, but is not yet in entity
888
            var = next(varmaker)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
889
890
            rql.append('%s %s %s' % (V, attr, var))
            selected.append((attr, var))
891
        # +1 since this doesn't include the main variable
Adrien Di Mascio's avatar
Adrien Di Mascio committed
892
        lastattr = len(selected) + 1
893
894
895
        # don't fetch extra relation if attributes specified or of the entity is
        # coming from an external source (may lead to error)
        if attributes is None and self.cw_metainformation()['source']['uri'] == 'system':
Adrien Di Mascio's avatar
Adrien Di Mascio committed
896
            # fetch additional relations (restricted to 0..1 relations)
897
            for rschema, role in self._cw_to_complete_relations():
Adrien Di Mascio's avatar
Adrien Di Mascio committed
898
                rtype = rschema.type
899
                if self.cw_relation_cached(rtype, role):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
900
                    continue
901
902
903
904
905
906
                # 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'
907
                var = next(varmaker)
908
909
910
                # 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
911
912
913
914
915
916
                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))
917
918
919
920
921
            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
922
            # handle attributes
923
            for i in range(1, lastattr):
924
                self.cw_attr_cache[str(selected[i-1][0])] = rset[i]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
925
            # handle relations
926
            for i in range(lastattr, len(rset)):
927
                rtype, role = selected[i-1][0]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
928
929
930
                value = rset[i]
                if value is None:
                    rrset = ResultSet([], rql, {'x': self.eid})
931
                    rrset.req = self._cw
Adrien Di Mascio's avatar
Adrien Di Mascio committed
932
                else:
933
                    rrset = self._cw.eid_rset(value)
934
                self.cw_set_relation_cache(rtype, role, rrset)
935

936
    def cw_attr_value(self, name):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
937
938
939
940
941
942
943
        """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:
944
            return self.cw_attr_cache[name]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
945
        except KeyError:
946
            if not self.cw_is_saved():
Adrien Di Mascio's avatar
Adrien Di Mascio committed
947
948
949
                return None
            rql = "Any A WHERE X eid %%(x)s, X %s A" % name
            try:
950
                rset = self._cw.execute(rql, {'x': self.eid})
Adrien Di Mascio's avatar
Adrien Di Mascio committed
951
            except Unauthorized:
952
                self.cw_attr_cache[name] = value = None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
953
954
955
            else:
                assert rset.rowcount <= 1, (self, rql, rset.rowcount)
                try:
956
                    self.cw_attr_cache[name] = value = rset.rows[0][0]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
957
958
959
960
961
                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':
962
                        self.cw_attr_cache[name] = value = self._cw._('unaccessible')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
963
                    else:
964
965
                        self.cw_attr_cache[name] = value = None
            return value
Adrien Di Mascio's avatar
Adrien Di Mascio committed
966

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

971
972
973
974
975
976
977
978
979
980
981
        :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
982
983
        :param targettypes:
          a tuple of target entity types to restrict the query
Adrien Di Mascio's avatar
Adrien Di Mascio committed
984
        """
985
        rtype = str(rtype)
986
987
988
        # Caching restricted/limited results is best avoided.
        cacheable = limit is None and targettypes is None
        if cacheable:
989
990
991
            cache_key = '%s_%s' % (rtype, role)
            if cache_key in self._cw_related_cache:
                return self._cw_related_cache[cache_key][entities]
992
993
994
        if not self.has_eid():
            if entities:
                return []
995
            return self._cw.empty_rset()
996
        rql = self.cw_related_rql(rtype, role, limit=limit, targettypes=targettypes)
997
998
999
1000
1001
1002
        try:
            rset = self._cw.execute(rql, {'x': self.eid})
        except Unauthorized:
            if not safe:
                raise
            rset = self._cw.empty_rset()