1# encoding: utf-8
2#
3# spyne - Copyright (C) Spyne contributors.
4#
5# This library is free software; you can redistribute it and/or
6# modify it under the terms of the GNU Lesser General Public
7# License as published by the Free Software Foundation; either
8# version 2.1 of the License, or (at your option) any later version.
9#
10# This library is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
13# Lesser General Public License for more details.
14#
15# You should have received a copy of the GNU Lesser General Public
16# License along with this library; if not, write to the Free Software
17# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301
18#
19
20"""Useful stuff to integrate Spyne with Django.
21
22* Django model <-> spyne type mapping
23* Service for common exception handling
24
25"""
26
27from __future__ import absolute_import
28
29import logging
30logger = logging.getLogger(__name__)
31
32import re
33
34from itertools import chain
35
36from django.core.exceptions import (ImproperlyConfigured, ObjectDoesNotExist,
37                                    ValidationError as DjValidationError)
38from django.core.validators import (slug_re,
39                                    MinLengthValidator, MaxLengthValidator)
40try:
41    from django.core.validators import comma_separated_int_list_re
42except ImportError:
43    comma_separated_int_list_re = re.compile(r'^[\d,]+$')
44
45from spyne.error import (ResourceNotFoundError, ValidationError as
46                         BaseValidationError, Fault)
47from spyne.model import primitive
48from spyne.model.complex import ComplexModelMeta, ComplexModelBase
49from spyne.service import Service
50from spyne.util.cdict import cdict
51from spyne.util.odict import odict
52from spyne.util.six import add_metaclass
53
54
55# regex is based on http://www.w3.org/TR/xforms20/#xforms:email
56email_re = re.compile(
57    r"[A-Za-z0-9!#-'\*\+\-/=\?\^_`\{-~]+"
58    r"(\.[A-Za-z0-9!#-'\*\+\-/=\?\^_`\{-~]+)*@"
59    # domain part is either a single symbol
60    r"(([a-zA-Z0-9]|"
61    # or have at least two symbols
62    # hyphen can't be at the beginning or end of domain part
63    # domain should contain at least 2 parts, the last one is TLD
64    r"([a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])+)\.)+"
65    # TLD should contain only letters, at least 2
66    r"[A-Za-z]{2,}", re.IGNORECASE)
67
68
69def _handle_minlength(validator, params):
70    new_min = validator.limit_value
71    old_min = params.setdefault('min_len', new_min)
72    params['min_len'] = max(old_min, new_min)
73
74
75def _handle_maxlength(validator, params):
76    new_max = validator.limit_value
77    old_max = params.setdefault('max_len', new_max)
78    params['max_len'] = min(old_max, new_max)
79
80
81class BaseDjangoFieldMapper(object):
82
83    """Abstrace base class for field mappers."""
84
85    _VALIDATOR_HANDLERS = cdict({
86        MinLengthValidator: _handle_minlength,
87        MaxLengthValidator: _handle_maxlength,
88    })
89
90    @staticmethod
91    def is_field_nullable(field, **kwargs):
92        """Return True if django field is nullable."""
93        return field.null
94
95    @staticmethod
96    def is_field_blank(field, **kwargs):
97        """Return True if django field is blank."""
98        return field.blank
99
100    def map(self, field, **kwargs):
101        """Map field to spyne model.
102
103        :param field: Django Field instance
104        :param kwargs: Extra params to configure spyne model
105        :returns: tuple (field attribute name, mapped spyne model)
106
107        """
108        params = kwargs.copy()
109
110        self._process_validators(field.validators, params)
111
112        nullable = self.is_field_nullable(field, **kwargs)
113        blank = self.is_field_blank(field, **kwargs)
114        required = not (field.has_default() or blank or field.primary_key)
115
116        if field.has_default():
117            params['default'] = field.get_default()
118
119        spyne_model = self.get_spyne_model(field, **kwargs)
120        customized_model = spyne_model(nullable=nullable,
121                                       min_occurs=int(required), **params)
122
123        return (field.attname, customized_model)
124
125    def get_spyne_model(self, field, **kwargs):
126        """Return spyne model for given Django field."""
127        raise NotImplementedError
128
129    def _process_validators(self, validators, params):
130        for v in validators:
131            handler = self._VALIDATOR_HANDLERS.get(type(v))
132            if handler:
133                handler(v, params)
134
135
136class DjangoFieldMapper(BaseDjangoFieldMapper):
137
138    """Basic mapper for django fields."""
139
140    def __init__(self, spyne_model):
141        """Django field mapper constructor."""
142        self.spyne_model = spyne_model
143
144    def get_spyne_model(self, field, **kwargs):
145        """Return configured spyne model."""
146        return self.spyne_model
147
148
149class DecimalMapper(DjangoFieldMapper):
150
151    """Mapper for DecimalField."""
152
153    def map(self, field, **kwargs):
154        """Map DecimalField to spyne model.
155
156        :returns: tuple (field attribute name, mapped spyne model)
157
158        """
159        params = kwargs.copy()
160        params.update({
161            'total_digits': field.max_digits,
162            'fraction_digits': field.decimal_places,
163        })
164        return super(DecimalMapper, self).map(field, **params)
165
166
167class RelationMapper(BaseDjangoFieldMapper):
168
169    """Mapper for relation fields (ForeignKey, OneToOneField)."""
170
171    def __init__(self, django_model_mapper):
172        """Constructor for relation field mapper."""
173        self.django_model_mapper = django_model_mapper
174
175    @staticmethod
176    def is_field_blank(field, **kwargs):
177        """Return True if `optional_relations` is set.
178
179        Otherwise use basic behaviour.
180
181        """
182        optional_relations = kwargs.get('optional_relations', False)
183        return (optional_relations or
184                BaseDjangoFieldMapper.is_field_blank(field, **kwargs))
185
186    def get_spyne_model(self, field, **kwargs):
187        """Return spyne model configured by related field."""
188        related_field = field.rel.get_related_field() if hasattr(field, 'rel') else field.remote_field.get_related_field()
189        field_type = related_field.__class__.__name__
190        field_mapper = self.django_model_mapper.get_field_mapper(field_type)
191
192        _, related_spyne_model = field_mapper.map(related_field, **kwargs)
193        return related_spyne_model
194
195
196class DjangoModelMapper(object):
197
198    r"""Mapper from django models to spyne complex models.
199
200    You can extend it registering new field types: ::
201
202        class NullBooleanMapper(DjangoFieldMapper):
203
204            def map(self, field, **kwargs):
205                params = kwargs.copy()
206                # your mapping logic goes here
207                return super(NullBooleanMapper, self).map(field, **params)
208
209        default_model_mapper.register_field_mapper('NullBooleanField', \
210                NullBooleanMapper(primitive.Boolean))
211
212
213    You may subclass it if you want different mapping logic for different
214    Django models.
215
216    """
217
218    field_mapper_class = DjangoFieldMapper
219
220    class UnknownFieldMapperException(Exception):
221
222        """Raises when there is no field mapper for given django_type."""
223
224    def __init__(self, django_spyne_models=()):
225        """Register field mappers in internal registry."""
226        self._registry = {}
227
228        for django_type, spyne_model in django_spyne_models:
229            self.register(django_type, spyne_model)
230
231    def get_field_mapper(self, django_type):
232        """Get mapper registered for given django_type.
233
234        :param django_type: Django internal field type
235        :returns: registered mapper
236        :raises: :exc:`UnknownFieldMapperException`
237
238        """
239        try:
240            return self._registry[django_type]
241        except KeyError:
242            raise self.UnknownFieldMapperException(
243                'No mapper for field type {0}'.format(django_type))
244
245    def register(self, django_type, spyne_model):
246        """Register default field mapper for django_type and spyne_model.
247
248        :param django_type: Django internal field type
249        :param spyne_model: Spyne model, usually primitive
250
251        """
252        field_mapper = self.field_mapper_class(spyne_model)
253        self.register_field_mapper(django_type, field_mapper)
254
255    def register_field_mapper(self, django_type, field_mapper):
256        """Register field mapper for django_type.
257
258        :param django_type: Django internal field type
259        :param field_mapper: :class:`DjangoFieldMapper` instance
260
261        """
262        self._registry[django_type] = field_mapper
263
264    @staticmethod
265    def get_all_field_names(meta):
266        if hasattr(meta, 'get_all_field_names'):
267            return meta.get_all_field_names()
268
269        return list(set(chain.from_iterable(
270            (field.name, field.attname) if hasattr(field, 'attname') else (
271            field.name,)
272            for field in meta.get_fields()
273            # For complete backwards compatibility, you may want to exclude
274            # GenericForeignKey from the results.
275            if not (field.many_to_one and field.related_model is None)
276        )))
277
278    @staticmethod
279    def _get_fields(django_model, exclude=None):
280        field_names = set(exclude) if exclude is not None else set()
281        meta = django_model._meta  # pylint: disable=W0212
282        unknown_fields_names = \
283             field_names.difference(DjangoModelMapper.get_all_field_names(meta))
284
285        if unknown_fields_names:
286            raise ImproperlyConfigured(
287                'Unknown field names: {0}'
288                .format(', '.join(unknown_fields_names)))
289
290        return [field for field in meta.fields if field.name not in
291                field_names]
292
293    def map(self, django_model, exclude=None, **kwargs):
294        """Prepare dict of model fields mapped to spyne models.
295
296        :param django_model: Django model class.
297        :param exclude: list of fields excluded from mapping.
298        :param kwargs: extra kwargs are passed to all field mappers
299
300        :returns: dict mapping attribute names to spyne models
301        :raises: :exc:`UnknownFieldMapperException`
302
303        """
304        field_map = odict()
305
306        for field in self._get_fields(django_model, exclude):
307            field_type = field.__class__.__name__
308
309            try:
310                field_mapper = self._registry[field_type]
311            except KeyError:
312                # mapper for this field is not registered
313                if not (field.has_default() or field.null):
314                    # field is required
315                    raise self.UnknownFieldMapperException(
316                        'No mapper for field type {0}'.format(field_type))
317                else:
318                    # skip this field
319                    logger.info('Field {0} is skipped from mapping.')
320                    continue
321
322            attr_name, spyne_model = field_mapper.map(field, **kwargs)
323            field_map[attr_name] = spyne_model
324
325        return field_map
326
327
328def strip_regex_metachars(pattern):
329    """Strip ^ and $ from pattern begining and end.
330
331    According to http://www.w3.org/TR/xmlschema-0/#regexAppendix XMLSchema
332    expression language does not contain the metacharacters ^ and $.
333
334    :returns: stripped pattern string
335
336    """
337    start = 0
338    till = len(pattern)
339
340    if pattern.startswith('^'):
341        start = 1
342
343    if pattern.endswith('$'):
344        till -= 1
345
346    return pattern[start:till]
347
348
349# django's own slug_re.pattern is invalid according to xml schema -- it doesn't
350# like the location of the dash character. using the equivalent pattern accepted
351# by xml schema here.
352SLUG_RE_PATTERN = '[a-zA-Z0-9_-]+'
353
354
355DEFAULT_FIELD_MAP = (
356    ('AutoField', primitive.Integer32),
357    ('CharField', primitive.NormalizedString),
358    ('SlugField', primitive.Unicode(
359        type_name='Slug', pattern=strip_regex_metachars(SLUG_RE_PATTERN))),
360    ('TextField', primitive.Unicode),
361    ('EmailField', primitive.Unicode(
362        type_name='Email', pattern=strip_regex_metachars(email_re.pattern))),
363    ('CommaSeparatedIntegerField', primitive.Unicode(
364        type_name='CommaSeparatedField',
365        pattern=strip_regex_metachars(comma_separated_int_list_re.pattern))),
366    ('URLField', primitive.AnyUri),
367    ('FilePathField', primitive.Unicode),
368
369    ('BooleanField', primitive.Boolean),
370    ('NullBooleanField', primitive.Boolean),
371    ('IntegerField', primitive.Integer),
372    ('BigIntegerField', primitive.Integer64),
373    ('PositiveIntegerField', primitive.UnsignedInteger32),
374    ('SmallIntegerField', primitive.Integer16),
375    ('PositiveSmallIntegerField', primitive.UnsignedInteger16),
376    ('FloatField', primitive.Double),
377
378    ('TimeField', primitive.Time),
379    ('DateField', primitive.Date),
380    ('DateTimeField', primitive.DateTime),
381
382    # simple fixed defaults for relation fields
383    ('ForeignKey', primitive.Integer32),
384    ('OneToOneField', primitive.Integer32),
385)
386
387
388def model_mapper_factory(mapper_class, field_map):
389    """Factory for model mappers.
390
391    The factory is useful to create custom field mappers based on default one.
392
393    """
394    model_mapper = mapper_class(field_map)
395
396    # register relation field mappers that are aware of related field type
397    model_mapper.register_field_mapper(
398        'ForeignKey', RelationMapper(model_mapper))
399
400    model_mapper.register_field_mapper(
401        'OneToOneField', RelationMapper(model_mapper))
402
403    model_mapper.register_field_mapper('DecimalField',
404                                       DecimalMapper(primitive.Decimal))
405    return model_mapper
406
407
408default_model_mapper = model_mapper_factory(DjangoModelMapper,
409                                            DEFAULT_FIELD_MAP)
410
411
412class DjangoComplexModelMeta(ComplexModelMeta):
413
414    """Meta class for complex spyne models representing Django models."""
415
416    def __new__(mcs, name, bases, attrs):  # pylint: disable=C0202
417        """Populate new complex type from configured Django model."""
418        super_new = super(DjangoComplexModelMeta, mcs).__new__
419
420        abstract = bool(attrs.get('__abstract__', False))
421
422        if abstract:
423            # skip processing of abstract models
424            return super_new(mcs, name, bases, attrs)
425
426        attributes = attrs.get('Attributes')
427
428        if attributes is None:
429            raise ImproperlyConfigured('You have to define Attributes and '
430                                       'specify Attributes.django_model')
431
432        if getattr(attributes, 'django_model', None) is None:
433            raise ImproperlyConfigured('You have to define django_model '
434                                       'attribute in Attributes')
435
436        mapper = getattr(attributes, 'django_mapper', default_model_mapper)
437        attributes.django_mapper = mapper
438        exclude = getattr(attributes, 'django_exclude', None)
439        optional_relations = getattr(attributes, 'django_optional_relations',
440                                     False)
441        spyne_attrs = mapper.map(attributes.django_model, exclude=exclude,
442                                 optional_relations=optional_relations)
443        spyne_attrs.update(attrs)
444        return super_new(mcs, name, bases, spyne_attrs)
445
446
447@add_metaclass(DjangoComplexModelMeta)
448class DjangoComplexModel(ComplexModelBase):
449
450    """Base class with Django model mapping support.
451
452    Sample usage: ::
453
454        class PersonType(DjangoComplexModel):
455            class Attributes(DjangoComplexModel.Attributes):
456                django_model = Person
457
458
459    Attribute :attr:`django_model` is required for Django model mapping
460    machinery. You can customize your types defining custom type fields: ::
461
462        class PersonType(DjangoComplexModel):
463            gender = primitive.Unicode(pattern='^[FM]$')
464
465            class Attributes(DjangoComplexModel.Attributes):
466                django_model = Person
467
468
469    There is an option to specify custom mapper: ::
470
471        class PersonType(DjangoComplexModel):
472            class Attributes(DjangoComplexModel.Attributes):
473                django_model = Person
474                django_mapper = my_custom_mapper
475
476    You can also exclude some fields from mapping: ::
477
478        class PersonType(DjangoComplexModel):
479            class Attributes(DjangoComplexModel.Attributes):
480                django_model = Person
481                django_exclude = ['phone']
482
483    You may set `django_optional_relations`` attribute flag to indicate
484    that relation fields (ForeignKey, OneToOneField) of your model are
485    optional.  This is useful when you want to create base and related
486    instances in remote procedure. In this case primary key of base model is
487    not yet available.
488
489    """
490
491    __abstract__ = True
492
493
494class ObjectNotFoundError(ResourceNotFoundError):
495
496    """Fault constructed from `model.DoesNotExist` exception."""
497
498    def __init__(self, does_not_exist_exc):
499        """Construct fault with code Client.<object_name>NotFound."""
500        message = str(does_not_exist_exc)
501        object_name = message.split()[0]
502        # we do not want to reuse initialization of ResourceNotFoundError
503        Fault.__init__(
504            self, faultcode='Client.{0}NotFound'.format(object_name),
505            faultstring=message)
506
507
508class ValidationError(BaseValidationError):
509
510    """Fault constructed from `ValidationError` exception."""
511
512    def __init__(self, validation_error_exc):
513        """Construct fault with code Client.<validation_error_type_name>."""
514        message = str(validation_error_exc)
515        # we do not want to reuse initialization of BaseValidationError
516        Fault.__init__(
517            self, faultcode='Client.{0}'.format(
518                type(validation_error_exc).__name__), faultstring=message)
519
520
521class DjangoService(Service):
522
523    """Service with common Django exception handling."""
524
525    @classmethod
526    def call_wrapper(cls, ctx):
527        """Handle common Django exceptions."""
528        try:
529            out_object = super(DjangoService, cls).call_wrapper(ctx)
530        except ObjectDoesNotExist as e:
531            raise ObjectNotFoundError(e)
532        except DjValidationError as e:
533            raise ValidationError(e)
534        return out_object
535
536
537# FIXME: To be removed in Spyne 3
538DjangoServiceBase = DjangoService
539