adapters.py 15.4 KB
Newer Older
Sylvain Thénault's avatar
Sylvain Thénault committed
1
# copyright 2010-2015 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
#
# CubicWeb is free software: you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
"""some basic entity adapter implementations, for interfaces used in the
framework itself.
"""
21
from cubicweb import _
22

23
from itertools import chain
24
from hashlib import md5
25

26
from logilab.mtconverter import TransformError
27
from logilab.common.decorators import cached
28

Sylvain Thénault's avatar
Sylvain Thénault committed
29
from cubicweb import ValidationError, view, ViolatedConstraint, UniqueTogetherError
Aurelien Campeas's avatar
Aurelien Campeas committed
30
from cubicweb.predicates import is_instance, relation_possible, match_exception
31
32


33
class IEmailableAdapter(view.EntityAdapter):
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
    __regid__ = 'IEmailable'
    __select__ = relation_possible('primary_email') | relation_possible('use_email')

    def get_email(self):
        if getattr(self.entity, 'primary_email', None):
            return self.entity.primary_email[0].address
        if getattr(self.entity, 'use_email', None):
            return self.entity.use_email[0].address
        return None

    def allowed_massmail_keys(self):
        """returns a set of allowed email substitution keys

        The default is to return the entity's attribute list but you might
        override this method to allow extra keys.  For instance, a Person
        class might want to return a `companyname` key.
        """
        return set(rschema.type
                   for rschema, attrtype in self.entity.e_schema.attribute_definitions()
                   if attrtype.type not in ('Password', 'Bytes'))

    def as_email_context(self):
        """returns the dictionary as used by the sendmail controller to
        build email bodies.

        NOTE: the dictionary keys should match the list returned by the
        `allowed_massmail_keys` method.
        """
Sylvain Thénault's avatar
Sylvain Thénault committed
62
63
        return dict((attr, getattr(self.entity, attr))
                    for attr in self.allowed_massmail_keys())
64
65


66
class INotifiableAdapter(view.EntityAdapter):
67
    __regid__ = 'INotifiable'
68
    __select__ = is_instance('Any')
69
70
71
72
73
74
75
76
77
78
79

    def notification_references(self, view):
        """used to control References field of email send on notification
        for this entity. `view` is the notification view.

        Should return a list of eids which can be used to generate message
        identifiers of previously sent email(s)
        """
        itree = self.entity.cw_adapt_to('ITree')
        if itree is not None:
            return itree.path()[:-1]
80
81
        if view.msgid_timestamp:
            return (self.entity.eid,)
82
83
84
        return ()


85
class IFTIndexableAdapter(view.EntityAdapter):
86
87
88
89
90
    """standard adapter to handle fulltext indexing

    .. automethod:: cubicweb.entities.adapters.IFTIndexableAdapter.fti_containers
    .. automethod:: cubicweb.entities.adapters.IFTIndexableAdapter.get_words
    """
91
    __regid__ = 'IFTIndexable'
92
    __select__ = is_instance('Any')
93
94

    def fti_containers(self, _done=None):
95
96
97
98
99
        """return the list of entities to index when handling ``self.entity``

        The actual list of entities depends on ``fulltext_container`` usage
        in the datamodel definition
        """
100
101
102
103
104
105
        if _done is None:
            _done = set()
        entity = self.entity
        _done.add(entity.eid)
        containers = tuple(entity.e_schema.fulltext_containers())
        if containers:
106
107
            for rschema, role in containers:
                if role == 'object':
108
109
110
                    targets = getattr(entity, rschema.type)
                else:
                    targets = getattr(entity, 'reverse_%s' % rschema)
111
112
                for target in targets:
                    if target.eid in _done:
113
                        continue
114
                    for container in target.cw_adapt_to('IFTIndexable').fti_containers(_done):
115
116
117
118
                        yield container
        else:
            yield entity

119
120
121
122
    # weight in ABCD
    entity_weight = 1.0
    attr_weight = {}

123
124
125
126
127
128
129
130
131
132
133
134
135
    def get_words(self):
        """used by the full text indexer to get words to index

        this method should only be used on the repository side since it depends
        on the logilab.database package

        :rtype: list
        :return: the list of indexable word of this entity
        """
        from logilab.database.fti import tokenize
        # take care to cases where we're modyfying the schema
        entity = self.entity
        pending = self._cw.transaction_data.setdefault('pendingrdefs', set())
136
        words = {}
137
138
139
        for rschema in entity.e_schema.indexable_attributes():
            if (entity.e_schema, rschema) in pending:
                continue
140
            weight = self.attr_weight.get(rschema, 'C')
141
            try:
Sylvain Thénault's avatar
Sylvain Thénault committed
142
                value = entity.printable_value(rschema, format=u'text/plain')
143
144
            except TransformError:
                continue
145
            except Exception:
146
147
148
149
                self.exception("can't add value of %s to text index for entity %s",
                               rschema, entity.eid)
                continue
            if value:
150
                words.setdefault(weight, []).extend(tokenize(value))
151
152
        for rschema, role in entity.e_schema.fulltext_relations():
            if role == 'subject':
153
                for entity_ in getattr(entity, rschema.type):
154
                    merge_weight_dict(words, entity_.cw_adapt_to('IFTIndexable').get_words())
Sylvain Thénault's avatar
Sylvain Thénault committed
155
            else:  # if role == 'object':
156
                for entity_ in getattr(entity, 'reverse_%s' % rschema.type):
157
                    merge_weight_dict(words, entity_.cw_adapt_to('IFTIndexable').get_words())
158
159
        return words

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

161
def merge_weight_dict(maindict, newdict):
162
    for weight, words in newdict.items():
163
        maindict.setdefault(weight, []).extend(words)
164

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

166
class IDownloadableAdapter(view.EntityAdapter):
167
168
    """interface for downloadable entities"""
    __regid__ = 'IDownloadable'
Aurelien Campeas's avatar
Aurelien Campeas committed
169
    __abstract__ = True
170

Sylvain Thénault's avatar
Sylvain Thénault committed
171
    def download_url(self, **kwargs):  # XXX not really part of this interface
172
173
        """return a URL to download entity's content

Sylvain Thénault's avatar
Sylvain Thénault committed
174
175
        It should be a unicode object containing url-encoded ASCII.
        """
176
        raise NotImplementedError
Aurelien Campeas's avatar
Aurelien Campeas committed
177

178
    def download_content_type(self):
179
        """return MIME type (unicode) of the downloadable content"""
180
        raise NotImplementedError
Aurelien Campeas's avatar
Aurelien Campeas committed
181

182
    def download_encoding(self):
183
        """return encoding (unicode) of the downloadable content"""
184
        raise NotImplementedError
Aurelien Campeas's avatar
Aurelien Campeas committed
185

186
    def download_file_name(self):
187
        """return file name (unicode) of the downloadable content"""
188
        raise NotImplementedError
Aurelien Campeas's avatar
Aurelien Campeas committed
189

190
    def download_data(self):
191
        """return actual data (bytes) of the downloadable content"""
192
        raise NotImplementedError
193

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

Sylvain Thénault's avatar
Sylvain Thénault committed
195
# XXX should propose to use two different relations for children/parent
196
class ITreeAdapter(view.EntityAdapter):
197
    """This adapter provides a tree interface.
198

199
200
201
    It has to be overriden to be configured using the tree_relation,
    child_role and parent_role class attributes to benefit from this default
    implementation.
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218

    This class provides the following methods:

    .. automethod: iterparents
    .. automethod: iterchildren
    .. automethod: prefixiter

    .. automethod: is_leaf
    .. automethod: is_root

    .. automethod: root
    .. automethod: parent
    .. automethod: children
    .. automethod: different_type_children
    .. automethod: same_type_children
    .. automethod: children_rql
    .. automethod: path
219
220
    """
    __regid__ = 'ITree'
Aurelien Campeas's avatar
Aurelien Campeas committed
221
    __abstract__ = True
222
223
224
225
226

    child_role = 'subject'
    parent_role = 'object'

    def children_rql(self):
227
        """Returns RQL to get the children of the entity."""
228
229
230
        return self.entity.cw_related_rql(self.tree_relation, self.parent_role)

    def different_type_children(self, entities=True):
231
        """Return children entities of different type as this entity.
232

233
234
        According to the `entities` parameter, return entity objects or the
        equivalent result set.
235
236
237
238
239
240
241
242
243
        """
        res = self.entity.related(self.tree_relation, self.parent_role,
                                  entities=entities)
        eschema = self.entity.e_schema
        if entities:
            return [e for e in res if e.e_schema != eschema]
        return res.filtered_rset(lambda x: x.e_schema != eschema, self.entity.cw_col)

    def same_type_children(self, entities=True):
244
        """Return children entities of the same type as this entity.
245

246
247
        According to the `entities` parameter, return entity objects or the
        equivalent result set.
248
249
250
251
252
253
254
255
256
        """
        res = self.entity.related(self.tree_relation, self.parent_role,
                                  entities=entities)
        eschema = self.entity.e_schema
        if entities:
            return [e for e in res if e.e_schema == eschema]
        return res.filtered_rset(lambda x: x.e_schema is eschema, self.entity.cw_col)

    def is_leaf(self):
257
        """Returns True if the entity does not have any children."""
258
259
260
        return len(self.children()) == 0

    def is_root(self):
261
262
        """Returns true if the entity is root of the tree (e.g. has no parent).
        """
263
264
265
        return self.parent() is None

    def root(self):
266
        """Return the root entity of the tree."""
267
268
269
        return self._cw.entity_from_eid(self.path()[0])

    def parent(self):
270
271
        """Returns the parent entity if any, else None (e.g. if we are on the
        root).
272
273
274
275
276
277
278
279
        """
        try:
            return self.entity.related(self.tree_relation, self.child_role,
                                       entities=True)[0]
        except (KeyError, IndexError):
            return None

    def children(self, entities=True, sametype=False):
280
        """Return children entities.
281

282
283
        According to the `entities` parameter, return entity objects or the
        equivalent result set.
284
285
286
287
288
289
290
291
        """
        if sametype:
            return self.same_type_children(entities)
        else:
            return self.entity.related(self.tree_relation, self.parent_role,
                                       entities=entities)

    def iterparents(self, strict=True):
292
        """Return an iterator on the parents of the entity."""
293
294
295
296
297
298
299
300
301
302
303
304
305
        def _uptoroot(self):
            curr = self
            while True:
                curr = curr.parent()
                if curr is None:
                    break
                yield curr
                curr = curr.cw_adapt_to('ITree')
        if not strict:
            return chain([self.entity], _uptoroot(self))
        return _uptoroot(self)

    def iterchildren(self, _done=None):
306
        """Return an iterator over the item's children."""
307
308
309
310
        if _done is None:
            _done = set()
        for child in self.children():
            if child.eid in _done:
311
                self.error('loop in %s tree: %s', child.cw_etype.lower(), child)
312
313
314
315
316
                continue
            yield child
            _done.add(child.eid)

    def prefixiter(self, _done=None):
317
        """Return an iterator over the item's descendants in a prefixed order."""
318
319
320
321
322
323
324
325
326
327
        if _done is None:
            _done = set()
        if self.entity.eid in _done:
            return
        _done.add(self.entity.eid)
        yield self.entity
        for child in self.same_type_children():
            for entity in child.cw_adapt_to('ITree').prefixiter(_done):
                yield entity

328
    @cached
329
    def path(self):
330
        """Returns the list of eids from the root object to this object."""
331
332
333
334
335
        path = []
        adapter = self
        entity = adapter.entity
        while entity is not None:
            if entity.eid in path:
336
                self.error('loop in %s tree: %s', entity.cw_etype.lower(), entity)
337
338
339
340
341
                break
            path.append(entity.eid)
            try:
                # check we are not jumping to another tree
                if (adapter.tree_relation != self.tree_relation or
Sylvain Thénault's avatar
Sylvain Thénault committed
342
                        adapter.child_role != self.child_role):
343
344
345
346
347
348
349
350
                    break
                entity = adapter.parent()
                adapter = entity.cw_adapt_to('ITree')
            except AttributeError:
                break
        path.reverse()
        return path

351

352
353
354
355
356
357
358
359
360
361
362
363
364
365
class ISerializableAdapter(view.EntityAdapter):
    """Adapter to serialize an entity to a bare python structure that may be
    directly serialized to e.g. JSON.
    """

    __regid__ = 'ISerializable'
    __select__ = is_instance('Any')

    def serialize(self):
        entity = self.entity
        entity.complete()
        data = {
            'cw_etype': entity.cw_etype,
            'cw_source': entity.cw_metainformation()['source']['uri'],
366
            'eid': entity.eid,
367
368
369
370
371
372
373
374
375
376
377
378
        }
        for rschema, __ in entity.e_schema.attribute_definitions():
            attr = rschema.type
            try:
                value = entity.cw_attr_cache[attr]
            except KeyError:
                # Bytes
                continue
            data[attr] = value
        return data


379
380
381
382
383
384
# error handling adapters ######################################################


class IUserFriendlyError(view.EntityAdapter):
    __regid__ = 'IUserFriendlyError'
    __abstract__ = True
385

386
387
388
389
390
391
392
    def __init__(self, *args, **kwargs):
        self.exc = kwargs.pop('exc')
        super(IUserFriendlyError, self).__init__(*args, **kwargs)


class IUserFriendlyUniqueTogether(IUserFriendlyError):
    __select__ = match_exception(UniqueTogetherError)
393

394
    def raise_user_exception(self):
395
        rtypes = self.exc.rtypes
396
397
398
        errors = {}
        msgargs = {}
        i18nvalues = []
399
        for rtype in rtypes:
400
401
402
403
404
            errors[rtype] = _('%(KEY-rtype)s is part of violated unicity constraint')
            msgargs[rtype + '-rtype'] = rtype
            i18nvalues.append(rtype + '-rtype')
        errors[''] = _('some relations violate a unicity constraint')
        raise ValidationError(self.entity.eid, errors, msgargs=msgargs, i18nvalues=i18nvalues)
405
406
407
408
409
410
411
412
413
414
415


class IUserFriendlyCheckConstraint(IUserFriendlyError):
    __select__ = match_exception(ViolatedConstraint)

    def raise_user_exception(self):
        cstrname = self.exc.cstrname
        eschema = self.entity.e_schema
        for rschema, attrschema in eschema.attribute_definitions():
            rdef = rschema.rdef(eschema, attrschema)
            for constraint in rdef.constraints:
Sylvain Thénault's avatar
Sylvain Thénault committed
416
417
418
                if cstrname == 'cstr' + md5(
                        (eschema.type + rschema.type + constraint.type() +
                         (constraint.serialize() or '')).encode('ascii')).hexdigest():
419
420
421
422
423
424
425
                    break
            else:
                continue
            break
        else:
            assert 0
        key = rschema.type + '-subject'
426
427
428
429
430
        # use .get since a constraint may be associated to an attribute that isn't edited (e.g.
        # constraint between two attributes). This should be the purpose of an api rework at some
        # point, we currently rely on the fact that such constraint will provide a dedicated user
        # message not relying on the `value` argument
        msg, args = constraint.failed_message(key, self.entity.cw_edited.get(rschema.type))
431
        raise ValidationError(self.entity.eid, {key: msg}, args)