entity.py 58 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
21
from warnings import warn

22
from six import text_type, string_types, integer_types
23
24
from six.moves import range

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

Adrien Di Mascio's avatar
Adrien Di Mascio committed
29
from rql.utils import rqlvar_maker
30
31
32
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
33

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

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

_marker = object()

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

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

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

67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
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
108
    for (info_rtype, info_role), eids in lt_infos.items():
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
        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 = {}
135
    for (lt_rtype, lt_role), eids in lt_infos.items():
136
137
138
139
140
141
142
143
144
145
        # 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
146

147

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

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

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

160
161
162
163
    :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)
164

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

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

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

201
    fetch_attrs = ('modification_date',)
202

203
    @classmethod
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
    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`
238
        """
239
        cls.cw_fetch_unrelated_order(select, attr, var)
240
241

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

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

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

    @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)
270
        elif isinstance(mainvar, string_types):
271
272
273
274
275
            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
276
        if settype:
277
278
279
280
            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
281
282
        if fetchattrs is None:
            fetchattrs = cls.fetch_attrs
283
284
        cls._fetch_restrictions(mainvar, select, fetchattrs, user, ordermethod)
        return select
285

286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
    @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
315
    @classmethod
316
317
    def _fetch_restrictions(cls, mainvar, select, fetchattrs,
                            user, ordermethod='fetch_order', visited=None):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
318
319
320
321
322
323
324
325
        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)
326
        for attr in sorted(fetchattrs):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
327
            try:
328
                rschema = eschema.subjrels[attr]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
329
330
            except KeyError:
                cls.warning('skipping fetch_attr %s defined in %s (not found in schema)',
331
                            attr, cls.__regid__)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
332
                continue
333
334
            # 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
335
            if not user.matching_groups(rdef.get_groups('read')):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
336
                continue
337
338
339
340
341
342
343
344
345
            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
346
            if not rschema.final:
347
348
349
350
                # 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.
351
                rel.change_optional('right')
352
                targettypes = rschema.objects(eschema.type)
353
                vreg = user._cw.vreg  # XXX user._cw.vreg iiiirk
354
                etypecls = vreg['etypes'].etype_class(targettypes[0])
355
356
                if len(targettypes) > 1:
                    # find fetch_attrs common to all destination types
357
                    fetchattrs = vreg['etypes'].fetch_attrs(targettypes)
Sylvain Thénault's avatar
Sylvain Thénault committed
358
                    # ... and handle ambiguous relations
359
360
                    cls._fetch_ambiguous_rtypes(select, var, fetchattrs,
                                                targettypes, vreg.schema)
361
362
                else:
                    fetchattrs = etypecls.fetch_attrs
363
                etypecls._fetch_restrictions(var, select, fetchattrs,
364
                                             user, None, visited=visited)
365
            if ordermethod is not None:
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
                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:
385
386
                                cls.error('ignore %s until %s is upgraded',
                                          orderterm, cmeth)
387
388
389
390
391
392
393
                                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
394

395
396
    @classmethod
    @cached
397
398
399
400
401
402
403
404
405
406
407
    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'
        """
408
409
410
411
412
413
        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():
414
                if (rschema.final
415
                    and rschema not in ('eid', 'cwuri')
416
417
                    and cls.e_schema.has_unique_values(rschema)
                    and cls.e_schema.rdef(rschema.type).cardinality[0] == '1'):
418
419
420
421
422
423
424
                    mainattr = str(rschema)
                    needcheck = False
                    break
        if mainattr == 'eid':
            needcheck = False
        return mainattr, needcheck

425
    @classmethod
426
    def _cw_build_entity_query(cls, kwargs):
427
428
        relations = []
        restrictions = set()
429
        pendingrels = []
430
        eschema = cls.e_schema
431
        qargs = {}
432
        attrcache = {}
433
        for attr, value in kwargs.items():
434
435
436
437
438
            if attr.startswith('reverse_'):
                attr = attr[len('reverse_'):]
                role = 'object'
            else:
                role = 'subject'
439
            assert eschema.has_relation(attr, role), '%s %s not found on %s' % (attr, role, eschema)
440
            rschema = eschema.subjrels[attr] if role == 'subject' else eschema.objrels[attr]
441
            if not rschema.final and isinstance(value, (tuple, list, set, frozenset)):
442
443
444
                if len(value) == 0:
                    continue # avoid crash with empty IN clause
                elif len(value) == 1:
445
                    value = next(iter(value))
446
                else:
447
                    # prepare IN clause
448
                    pendingrels.append( (attr, role, value) )
449
                    continue
450
451
            if rschema.final: # attribute
                relations.append('X %s %%(%s)s' % (attr, attr))
452
453
                attrcache[attr] = value
            elif value is None:
454
                pendingrels.append( (attr, role, value) )
455
            else:
456
                rvar = attr.upper()
457
458
                if role == 'object':
                    relations.append('%s %s X' % (rvar, attr))
459
460
461
462
463
                else:
                    relations.append('X %s %s' % (attr, rvar))
                restriction = '%s eid %%(%s)s' % (rvar, attr)
                if not restriction in restrictions:
                    restrictions.add(restriction)
464
465
466
                if hasattr(value, 'eid'):
                    value = value.eid
            qargs[attr] = value
467
        rql = u''
468
        if relations:
469
            rql += ', '.join(relations)
470
        if restrictions:
471
            rql += ' WHERE %s' % ', '.join(restrictions)
472
        return rql, qargs, pendingrels, attrcache
473
474

    @classmethod
475
476
    def _cw_handle_pending_relations(cls, eid, pendingrels, execute):
        for attr, role, values in pendingrels:
477
478
            if role == 'object':
                restr = 'Y %s X' % attr
479
480
            else:
                restr = 'X %s Y' % attr
481
482
483
            if values is None:
                execute('DELETE %s WHERE X eid %%(x)s' % restr, {'x': eid})
                continue
484
            execute('SET %s WHERE X eid %%(x)s, Y eid IN (%s)' % (
485
                restr, ','.join(str(getattr(r, 'eid', r)) for r in values)),
486
487
488
489
490
491
492
493
                    {'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
494
495
        >>> companycls = vreg['etypes'].etype_class('Company')
        >>> personcls = vreg['etypes'].etype_class('Person')
496
497
498
499
500
501
502
503
        >>> 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.
        """
504
        rql, qargs, pendingrels, attrcache = cls._cw_build_entity_query(kwargs)
505
506
507
508
        if rql:
            rql = 'INSERT %s X: %s' % (cls.__regid__, rql)
        else:
            rql = 'INSERT %s X' % (cls.__regid__)
509
510
511
512
513
        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))
514
        created._cw_update_attr_cache(attrcache)
515
        cls._cw_handle_pending_relations(created.eid, pendingrels, execute)
516
517
        return created

518
    def __init__(self, req, rset=None, row=None, col=0):
519
        AppObject.__init__(self, req, rset=rset, row=row, col=col)
520
        self._cw_related_cache = {}
521
        self._cw_adapters_cache = {}
Adrien Di Mascio's avatar
Adrien Di Mascio committed
522
523
524
525
        if rset is not None:
            self.eid = rset[row][col]
        else:
            self.eid = None
526
        self._cw_is_saved = True
527
        self.cw_attr_cache = {}
528

Adrien Di Mascio's avatar
Adrien Di Mascio committed
529
530
    def __repr__(self):
        return '<Entity %s %s %s at %s>' % (
531
            self.e_schema, self.eid, list(self.cw_attr_cache), id(self))
Adrien Di Mascio's avatar
Adrien Di Mascio committed
532

533
    def __lt__(self, other):
534
        return NotImplemented
535
536

    def __eq__(self, other):
Rémi Cardona's avatar
Rémi Cardona committed
537
        if isinstance(self.eid, integer_types):
538
539
540
541
            return self.eid == other.eid
        return self is other

    def __hash__(self):
Rémi Cardona's avatar
Rémi Cardona committed
542
        if isinstance(self.eid, integer_types):
543
544
            return self.eid
        return super(Entity, self).__hash__()
545

546
547
548
    def _cw_update_attr_cache(self, attrcache):
        trdata = self._cw.transaction_data
        uncached_attrs = trdata.get('%s.storage-special-process-attrs' % self.eid, set())
549
        uncached_attrs.update(trdata.get('%s.dont-cache-attrs' % self.eid, set()))
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
        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
567
568
569
        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)
570

571
572
573
574
    def __json_encode__(self):
        """custom json dumps hook to dump the entity's eid
        which is not part of dict structure itself
        """
575
        dumpable = self.cw_attr_cache.copy()
576
577
578
        dumpable['eid'] = self.eid
        return dumpable

579
580
581
582
583
    def cw_adapt_to(self, interface):
        """return an adapter the entity to the given interface name.

        return None if it can not be adapted.
        """
584
        cache = self._cw_adapters_cache
585
586
587
588
589
590
591
592
        try:
            return cache[interface]
        except KeyError:
            adapter = self._cw.vreg['adapters'].select_or_none(
                interface, self._cw, entity=self)
            cache[interface] = adapter
            return adapter

593
    def has_eid(self): # XXX cw_has_eid
Adrien Di Mascio's avatar
Adrien Di Mascio committed
594
595
596
597
        """return True if the entity has an attributed eid (False
        meaning that the entity has to be created
        """
        try:
598
            int(self.eid)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
599
600
601
602
            return True
        except (ValueError, TypeError):
            return False

603
    def cw_is_saved(self):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
604
        """during entity creation, there is some time during which the entity
605
606
607
        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
608
        """
609
        return self.has_eid() and self._cw_is_saved
610

611
    def cw_check_perm(self, action):
612
        self.e_schema.check_perm(self._cw, action, eid=self.eid)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
613

614
    def cw_has_perm(self, action):
615
        return self.e_schema.has_perm(self._cw, action, eid=self.eid)
616

617
    def view(self, __vid, __registry='views', w=None, initargs=None, **kwargs): # XXX cw_view
Adrien Di Mascio's avatar
Adrien Di Mascio committed
618
        """shortcut to apply a view on this entity"""
619
620
621
622
        if initargs is None:
            initargs = kwargs
        else:
            initargs.update(kwargs)
623
        view = self._cw.vreg[__registry].select(__vid, self._cw, rset=self.cw_rset,
624
                                                row=self.cw_row, col=self.cw_col,
625
                                                **initargs)
626
        return view.render(row=self.cw_row, col=self.cw_col, w=w, **kwargs)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
627

628
    def absolute_url(self, *args, **kwargs): # XXX cw_url
Adrien Di Mascio's avatar
Adrien Di Mascio committed
629
        """return an absolute url to view this entity"""
630
631
632
633
634
635
636
        # 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
637
        if method in (None, 'view'):
638
            kwargs['_restpath'] = self.rest_path()
Adrien Di Mascio's avatar
Adrien Di Mascio committed
639
640
        else:
            kwargs['rql'] = 'Any X WHERE X eid %s' % self.eid
641
        return self._cw.build_url(method, **kwargs)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
642

643
    def rest_path(self, *args, **kwargs): # XXX cw_rest_path
Adrien Di Mascio's avatar
Adrien Di Mascio committed
644
        """returns a REST-like (relative) path for this entity"""
645
646
        if args or kwargs:
            warn("[3.24] rest_path doesn't take parameters anymore", DeprecationWarning)
647
        mainattr, needcheck = self.cw_rest_attr_info()
Adrien Di Mascio's avatar
Adrien Di Mascio committed
648
        etype = str(self.e_schema)
649
        path = etype.lower()
650
        fallback = False
651
        if mainattr != 'eid':
652
            value = getattr(self, mainattr)
653
            if not can_use_rest_path(value):
654
                mainattr = 'eid'
655
                path = None
656
657
            elif needcheck:
                # make sure url is not ambiguous
658
659
660
661
662
663
                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]
664
665
                if nbresults != 1: # ambiguity?
                    mainattr = 'eid'
666
                    path = None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
667
        if mainattr == 'eid':
668
            value = self.eid
669
670
671
        if path is None:
            # fallback url: <base-url>/<eid> url is used as cw entities uri,
            # prefer it to <base-url>/<etype>/eid/<eid>
672
673
            return text_type(value)
        return u'%s/%s' % (path, self._cw.url_quote(value))
Adrien Di Mascio's avatar
Adrien Di Mascio committed
674

675
    def cw_attr_metadata(self, attr, metadata):
676
        """return a metadata for an attribute (None if unspecified)"""
677
        value = getattr(self, '%s_%s' % (attr, metadata), None)
678
        if value is None and metadata == 'encoding':
679
            value = self._cw.vreg.property_value('ui.encoding')
680
        return value
Adrien Di Mascio's avatar
Adrien Di Mascio committed
681
682

    def printable_value(self, attr, value=_marker, attrtype=None,
683
                        format='text/html', displaytime=True): # XXX cw_printable_value
Adrien Di Mascio's avatar
Adrien Di Mascio committed
684
685
686
687
688
        """return a displayable value (i.e. unicode string) which may contains
        html tags
        """
        attr = str(attr)
        if value is _marker:
689
            value = getattr(self, attr)
690
        if isinstance(value, string_types):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
691
692
693
694
695
            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
696
        props = self.e_schema.rdef(attr)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
697
698
699
        if attrtype == 'String':
            # internalinalized *and* formatted string such as schema
            # description...
Sylvain Thénault's avatar
Sylvain Thénault committed
700
            if props.internationalizable:
701
                value = self._cw._(value)
702
            attrformat = self.cw_attr_metadata(attr, 'format')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
703
            if attrformat:
704
705
                return self._cw_mtc_transform(value, attrformat, format,
                                              self._cw.encoding)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
706
        elif attrtype == 'Bytes':
707
            attrformat = self.cw_attr_metadata(attr, 'format')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
708
            if attrformat:
709
710
711
                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
712
            return u''
713
714
        value = self._cw.printable_value(attrtype, value, props,
                                         displaytime=displaytime)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
715
        if format == 'text/html':
Sylvain Thénault's avatar
Sylvain Thénault committed
716
            value = xml_escape(value)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
717
718
        return value

719
720
    def _cw_mtc_transform(self, data, format, target_format, encoding,
                          _engine=ENGINE):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
721
722
        trdata = TransformData(data, format, encoding, appobject=self)
        data = _engine.convert(trdata, target_format).decode()
723
        if target_format == 'text/html':
724
            data = soup2xhtml(data, self._cw.encoding)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
725
        return data
726

Adrien Di Mascio's avatar
Adrien Di Mascio committed
727
728
    # entity cloning ##########################################################

729
    def copy_relations(self, ceid): # XXX cw_copy_relations
Alexandre Fayolle's avatar
Alexandre Fayolle committed
730
731
732
        """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
733
734
735
736
737

        By default meta and composite relations are skipped.
        Overrides this if you want another behaviour
        """
        assert self.has_eid()
738
        execute = self._cw.execute
739
740
741
742
        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
743
        for rschema in self.e_schema.subject_relations():
744
745
            if rschema.type in skip_copy_for['subject']:
                continue
746
            if rschema.final or rschema.meta or rschema.rule:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
747
                continue
748
749
            # skip already defined relations
            if getattr(self, rschema.type):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
750
                continue
751
752
            # XXX takefirst=True to remove warning triggered by ambiguous relations
            rdef = self.e_schema.rdef(rschema, takefirst=True)
753
            # skip composite relation
Sylvain Thénault's avatar
Sylvain Thénault committed
754
            if rdef.composite:
755
756
757
                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
758
            if rdef.cardinality[1] in '?1':
759
                continue
Adrien Di Mascio's avatar
Adrien Di Mascio committed
760
            rql = 'SET X %s V WHERE X eid %%(x)s, Y eid %%(y)s, Y %s V' % (
761
                rschema.type, rschema.type)
762
            execute(rql, {'x': self.eid, 'y': ceid})
763
            self.cw_clear_relation_cache(rschema.type, 'subject')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
764
        for rschema in self.e_schema.object_relations():
765
            if rschema.meta or rschema.rule:
766
767
                continue
            # skip already defined relations
Sylvain Thénault's avatar
Sylvain Thénault committed
768
            if self.related(rschema.type, 'object'):
769
                continue
770
771
            if rschema.type in skip_copy_for['object']:
                continue
772
773
            # XXX takefirst=True to remove warning triggered by ambiguous relations
            rdef = self.e_schema.rdef(rschema, 'object', takefirst=True)
774
            # skip composite relation
Sylvain Thénault's avatar
Sylvain Thénault committed
775
            if rdef.composite:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
776
777
778
                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
779
            if rdef.cardinality[0] in '?1':
Adrien Di Mascio's avatar
Adrien Di Mascio committed
780
781
                continue
            rql = 'SET V %s X WHERE X eid %%(x)s, Y eid %%(y)s, V %s Y' % (
782
                rschema.type, rschema.type)
783
            execute(rql, {'x': self.eid, 'y': ceid})
784
            self.cw_clear_relation_cache(rschema.type, 'object')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
785
786
787

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

788
    @cached
789
    def as_rset(self): # XXX .cw_as_rset
Adrien Di Mascio's avatar
Adrien Di Mascio committed
790
791
        """returns a resultset containing `self` information"""
        rset = ResultSet([(self.eid,)], 'Any X WHERE X eid %(x)s',
792
                         {'x': self.eid}, [(self.cw_etype,)])
793
794
        rset.req = self._cw
        return rset
795

796
    def _cw_to_complete_relations(self):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
797
798
        """by default complete final relations to when calling .complete()"""
        for rschema in self.e_schema.subject_relations():
799
            if rschema.final:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
800
                continue
Sylvain Thénault's avatar
Sylvain Thénault committed
801
            targets = rschema.objects(self.e_schema)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
802
            if rschema.inlined:
803
                matching_groups = self._cw.user.matching_groups
804
805
806
                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
807
                    yield rschema, 'subject'
808

809
    def _cw_to_complete_attributes(self, skip_bytes=True, skip_pwd=True):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
810
811
812
813
814
815
816
        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
817
            # password retrieval is blocked at the repository server level
Sylvain Thénault's avatar
Sylvain Thénault committed
818
            rdef = rschema.rdef(self.e_schema, attrschema)
819
            if not self._cw.user.matching_groups(rdef.get_groups('read')) \
820
                   or (attrschema.type == 'Password' and skip_pwd):
821
                self.cw_attr_cache[attr] = None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
822
823
                continue
            yield attr
824

825
    _cw_completed = False
826
    def complete(self, attributes=None, skip_bytes=True, skip_pwd=True): # XXX cw_complete
Adrien Di Mascio's avatar
Adrien Di Mascio committed
827
828
829
830
831
832
833
834
        """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()
835
836
837
838
        if self._cw_completed:
            return
        if attributes is None:
            self._cw_completed = True
Adrien Di Mascio's avatar
Adrien Di Mascio committed
839
        varmaker = rqlvar_maker()
840
        V = next(varmaker)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
841
842
        rql = ['WHERE %s eid %%(x)s' % V]
        selected = []
843
        for attr in (attributes or self._cw_to_complete_attributes(skip_bytes, skip_pwd)):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
844
            # if attribute already in entity, nothing to do
845
            if attr in self.cw_attr_cache:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
846
847
                continue
            # case where attribute must be completed, but is not yet in entity
848
            var = next(varmaker)
Adrien Di Mascio's avatar
Adrien Di Mascio committed
849
850
            rql.append('%s %s %s' % (V, attr, var))
            selected.append((attr, var))
851
        # +1 since this doesn't include the main variable
Adrien Di Mascio's avatar
Adrien Di Mascio committed
852
        lastattr = len(selected) + 1
853
        if attributes is None:
Adrien Di Mascio's avatar
Adrien Di Mascio committed
854
            # fetch additional relations (restricted to 0..1 relations)
855
            for rschema, role in self._cw_to_complete_relations():
Adrien Di Mascio's avatar
Adrien Di Mascio committed
856
                rtype = rschema.type
857
                if self.cw_relation_cached(rtype, role):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
858
                    continue
859
860
861
862
863
864
                # 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'
865
                var = next(varmaker)
866
867
868
                # 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
869
870
871
872
873
874
                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))
875
876
877
878
879
            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
880
            # handle attributes
881
            for i in range(1, lastattr):
882
                self.cw_attr_cache[str(selected[i-1][0])] = rset[i]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
883
            # handle relations
884
            for i in range(lastattr, len(rset)):
885
                rtype, role = selected[i-1][0]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
886
887
888
                value = rset[i]
                if value is None:
                    rrset = ResultSet([], rql, {'x': self.eid})
889
                    rrset.req = self._cw
Adrien Di Mascio's avatar
Adrien Di Mascio committed
890
                else:
891
                    rrset = self._cw.eid_rset(value)
892
                self.cw_set_relation_cache(rtype, role, rrset)
893

894
    def cw_attr_value(self, name):
Adrien Di Mascio's avatar
Adrien Di Mascio committed
895
896
897
898
899
900
901
        """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:
902
            return self.cw_attr_cache[name]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
903
        except KeyError:
904
            if not self.cw_is_saved():
Adrien Di Mascio's avatar
Adrien Di Mascio committed
905
906
907
                return None
            rql = "Any A WHERE X eid %%(x)s, X %s A" % name
            try:
908
                rset = self._cw.execute(rql, {'x': self.eid})
Adrien Di Mascio's avatar
Adrien Di Mascio committed
909
            except Unauthorized:
910
                self.cw_attr_cache[name] = value = None
Adrien Di Mascio's avatar
Adrien Di Mascio committed
911
912
913
            else:
                assert rset.rowcount <= 1, (self, rql, rset.rowcount)
                try:
914
                    self.cw_attr_cache[name] = value = rset.rows[0][0]
Adrien Di Mascio's avatar
Adrien Di Mascio committed
915
916
917
918
919
                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':
920
                        self.cw_attr_cache[name] = value = self._cw._('unaccessible')
Adrien Di Mascio's avatar
Adrien Di Mascio committed
921
                    else:
922
923
                        self.cw_attr_cache[name] = value = None
            return value
Adrien Di Mascio's avatar
Adrien Di Mascio committed
924

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

929
930
931
932
933
934
935
936
937
938
939
        :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
940
941
        :param targettypes:
          a tuple of target entity types to restrict the query
Adrien Di Mascio's avatar
Adrien Di Mascio committed
942
        """
943
        rtype = str(rtype)
944
945
946
        # Caching restricted/limited results is best avoided.
        cacheable = limit is None and targettypes is None
        if cacheable:
947
948
949
            cache_key = '%s_%s' % (rtype, role)
            if cache_key in self._cw_related_cache:
                return self._cw_related_cache[cache_key][entities]
950
951
952
        if not self.has_eid():
            if entities:
                return []
953
            return self._cw.empty_rset()
954
        rql = self.cw_related_rql(rtype, role, limit=limit, targettypes=targettypes)
955
956
957
958
959
960
        try:
            rset = self._cw.execute(rql, {'x': self.eid})
        except Unauthorized:
            if not safe:
                raise
            rset = self._cw.empty_rset()
961
962
        if cacheable:
            self.cw_set_relation_cache(rtype, role, rset)
963
        if entities:
964
            return tuple(rset.entities())
965
966
        else:
            return rset
Adrien Di Mascio's avatar
Adrien Di Mascio committed
967

968
    def cw_related_rql(self, rtype, role='subject', targettypes=None, limit=None):
969
970
971
        return self.cw_related_rqlst(
            rtype, role=role, targettypes=targettypes, limit=limit).as_string()

972
973
974
975
976
977
978
979
980
981
982
983
984
    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.
        """
985
986
        vreg = self._cw.vreg
        rschema = vreg.schema[rtype]