adapters.py 17.8 KB
Newer Older
Sylvain Thénault's avatar
Sylvain Thénault committed
1
# copyright 2010-2016 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
24
from itertools import chain

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

28
29
from cubicweb import (Unauthorized, 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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class IDublinCoreAdapter(view.EntityAdapter):
    __regid__ = 'IDublinCore'
    __select__ = is_instance('Any')

    def title(self):
        """Return a suitable *unicode* title for entity"""
        entity = self.entity
        for rschema, attrschema in entity.e_schema.attribute_definitions():
            if rschema.meta:
                continue
            value = entity.cw_attr_value(rschema.type)
            if value is not None:
                # make the value printable (dates, floats, bytes, etc.)
                return entity.printable_value(
                    rschema.type, value, attrschema.type, format='text/plain')
        return u'%s #%s' % (self.type(), entity.eid)

    def long_title(self):
        """Return a more detailled title for entity"""
52
53
54
        # go through entity.dc_title for bw compat purpose: if entity define dc_title but not
        # dc_long_title, we still want it to be considered.
        return self.entity.dc_title()
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94

    def description(self, format='text/plain'):
        """Return a suitable description for entity"""
        if 'description' in self.entity.e_schema.subjrels:
            return self.entity.printable_value('description', format=format)
        return u''

    def authors(self):
        """Return a suitable description for the author(s) of the entity"""
        try:
            return u', '.join(u.name() for u in self.entity.owned_by)
        except Unauthorized:
            return u''

    def creator(self):
        """Return a suitable description for the creator of the entity"""
        if self.entity.creator:
            return self.entity.creator.name()
        return u''

    def date(self, date_format=None):  # XXX default to ISO 8601 ?
        """Return latest modification date of entity"""
        return self._cw.format_date(self.entity.modification_date,
                                    date_format=date_format)

    def type(self, form=''):
        """Return the display name for the type of entity (translated)"""
        return self.entity.e_schema.display_name(self._cw, form)

    def language(self):
        """Return language used by this entity (translated)"""
        eschema = self.entity.e_schema
        # check if entities has internationalizable attributes
        # XXX one is enough or check if all String attributes are internationalizable?
        for rschema, attrschema in eschema.attribute_definitions():
            if rschema.rdef(eschema, attrschema).internationalizable:
                return self._cw._(self._cw.user.property_value('ui.language'))
        return self._cw._(self._cw.vreg.property_value('ui.language'))


95
class IEmailableAdapter(view.EntityAdapter):
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
    __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
124
125
        return dict((attr, getattr(self.entity, attr))
                    for attr in self.allowed_massmail_keys())
126
127


128
class INotifiableAdapter(view.EntityAdapter):
129
    __regid__ = 'INotifiable'
130
    __select__ = is_instance('Any')
131
132
133
134
135
136
137
138
139
140
141

    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]
142
143
        if view.msgid_timestamp:
            return (self.entity.eid,)
144
145
146
        return ()


147
class IFTIndexableAdapter(view.EntityAdapter):
148
149
150
151
152
    """standard adapter to handle fulltext indexing

    .. automethod:: cubicweb.entities.adapters.IFTIndexableAdapter.fti_containers
    .. automethod:: cubicweb.entities.adapters.IFTIndexableAdapter.get_words
    """
153
    __regid__ = 'IFTIndexable'
154
    __select__ = is_instance('Any')
155
156

    def fti_containers(self, _done=None):
157
158
159
160
161
        """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
        """
162
163
164
165
166
167
        if _done is None:
            _done = set()
        entity = self.entity
        _done.add(entity.eid)
        containers = tuple(entity.e_schema.fulltext_containers())
        if containers:
168
169
            for rschema, role in containers:
                if role == 'object':
170
171
172
                    targets = getattr(entity, rschema.type)
                else:
                    targets = getattr(entity, 'reverse_%s' % rschema)
173
174
                for target in targets:
                    if target.eid in _done:
175
                        continue
176
                    for container in target.cw_adapt_to('IFTIndexable').fti_containers(_done):
177
178
179
180
                        yield container
        else:
            yield entity

181
182
183
184
    # weight in ABCD
    entity_weight = 1.0
    attr_weight = {}

185
186
187
188
189
190
191
192
193
194
195
196
197
    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())
198
        words = {}
199
200
201
        for rschema in entity.e_schema.indexable_attributes():
            if (entity.e_schema, rschema) in pending:
                continue
202
            weight = self.attr_weight.get(rschema, 'C')
203
            try:
Sylvain Thénault's avatar
Sylvain Thénault committed
204
                value = entity.printable_value(rschema, format=u'text/plain')
205
206
            except TransformError:
                continue
207
            except Exception:
208
209
210
211
                self.exception("can't add value of %s to text index for entity %s",
                               rschema, entity.eid)
                continue
            if value:
212
                words.setdefault(weight, []).extend(tokenize(value))
213
214
        for rschema, role in entity.e_schema.fulltext_relations():
            if role == 'subject':
215
                for entity_ in getattr(entity, rschema.type):
216
                    merge_weight_dict(words, entity_.cw_adapt_to('IFTIndexable').get_words())
Sylvain Thénault's avatar
Sylvain Thénault committed
217
            else:  # if role == 'object':
218
                for entity_ in getattr(entity, 'reverse_%s' % rschema.type):
219
                    merge_weight_dict(words, entity_.cw_adapt_to('IFTIndexable').get_words())
220
221
        return words

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

223
def merge_weight_dict(maindict, newdict):
224
    for weight, words in newdict.items():
225
        maindict.setdefault(weight, []).extend(words)
226

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

228
class IDownloadableAdapter(view.EntityAdapter):
229
230
    """interface for downloadable entities"""
    __regid__ = 'IDownloadable'
Aurelien Campeas's avatar
Aurelien Campeas committed
231
    __abstract__ = True
232

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

Sylvain Thénault's avatar
Sylvain Thénault committed
236
237
        It should be a unicode object containing url-encoded ASCII.
        """
238
        raise NotImplementedError
Aurelien Campeas's avatar
Aurelien Campeas committed
239

240
    def download_content_type(self):
241
        """return MIME type (unicode) of the downloadable content"""
242
        raise NotImplementedError
Aurelien Campeas's avatar
Aurelien Campeas committed
243

244
    def download_encoding(self):
245
        """return encoding (unicode) of the downloadable content"""
246
        raise NotImplementedError
Aurelien Campeas's avatar
Aurelien Campeas committed
247

248
    def download_file_name(self):
249
        """return file name (unicode) of the downloadable content"""
250
        raise NotImplementedError
Aurelien Campeas's avatar
Aurelien Campeas committed
251

252
    def download_data(self):
253
        """return actual data (bytes) of the downloadable content"""
254
        raise NotImplementedError
255

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

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

261
262
263
    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.
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280

    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
281
282
    """
    __regid__ = 'ITree'
Aurelien Campeas's avatar
Aurelien Campeas committed
283
    __abstract__ = True
284
285
286
287
288

    child_role = 'subject'
    parent_role = 'object'

    def children_rql(self):
289
        """Returns RQL to get the children of the entity."""
290
291
292
        return self.entity.cw_related_rql(self.tree_relation, self.parent_role)

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

295
296
        According to the `entities` parameter, return entity objects or the
        equivalent result set.
297
298
299
300
301
302
303
304
305
        """
        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):
306
        """Return children entities of the same type as this entity.
307

308
309
        According to the `entities` parameter, return entity objects or the
        equivalent result set.
310
311
312
313
314
315
316
317
318
        """
        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):
319
        """Returns True if the entity does not have any children."""
320
321
322
        return len(self.children()) == 0

    def is_root(self):
323
324
        """Returns true if the entity is root of the tree (e.g. has no parent).
        """
325
326
327
        return self.parent() is None

    def root(self):
328
        """Return the root entity of the tree."""
329
330
331
        return self._cw.entity_from_eid(self.path()[0])

    def parent(self):
332
333
        """Returns the parent entity if any, else None (e.g. if we are on the
        root).
334
335
336
337
338
339
340
341
        """
        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):
342
        """Return children entities.
343

344
345
        According to the `entities` parameter, return entity objects or the
        equivalent result set.
346
347
348
349
350
351
352
353
        """
        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):
354
        """Return an iterator on the parents of the entity."""
355
356
357
358
359
360
361
362
363
364
365
366
367
        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):
368
        """Return an iterator over the item's children."""
369
370
371
372
        if _done is None:
            _done = set()
        for child in self.children():
            if child.eid in _done:
373
                self.error('loop in %s tree: %s', child.cw_etype.lower(), child)
374
375
376
377
378
                continue
            yield child
            _done.add(child.eid)

    def prefixiter(self, _done=None):
379
        """Return an iterator over the item's descendants in a prefixed order."""
380
381
382
383
384
385
386
387
388
389
        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

390
    @cached
391
    def path(self):
392
        """Returns the list of eids from the root object to this object."""
393
394
395
396
397
        path = []
        adapter = self
        entity = adapter.entity
        while entity is not None:
            if entity.eid in path:
398
                self.error('loop in %s tree: %s', entity.cw_etype.lower(), entity)
399
400
401
402
403
                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
404
                        adapter.child_role != self.child_role):
405
406
407
408
409
410
411
412
                    break
                entity = adapter.parent()
                adapter = entity.cw_adapt_to('ITree')
            except AttributeError:
                break
        path.reverse()
        return path

413

414
415
416
417
418
419
420
421
422
423
424
425
426
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,
427
            'eid': entity.eid,
428
429
430
431
432
433
434
435
436
437
438
439
        }
        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


440
441
442
443
444
445
# error handling adapters ######################################################


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

447
448
449
450
451
452
453
    def __init__(self, *args, **kwargs):
        self.exc = kwargs.pop('exc')
        super(IUserFriendlyError, self).__init__(*args, **kwargs)


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

455
    def raise_user_exception(self):
456
        rtypes = self.exc.rtypes
457
458
459
        errors = {}
        msgargs = {}
        i18nvalues = []
460
        for rtype in rtypes:
461
462
463
464
465
            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)
466
467
468
469
470
471
472
473
474
475
476


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:
477
                if cstrname == constraint.name_for(rdef):
478
479
480
481
482
483
484
                    break
            else:
                continue
            break
        else:
            assert 0
        key = rschema.type + '-subject'
485
486
487
488
        # 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
Sylvain Thénault's avatar
Sylvain Thénault committed
489
490
        value = self.entity.cw_edited.get(rschema.type)
        msg, args = constraint.failed_message(key, value, self.entity)
491
        raise ValidationError(self.entity.eid, {key: msg}, args)