# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import datetime
import hashlib
import mimetypes
import re
from decimal import Decimal
import requests
import urlobject
from dateutil import parser
from django.conf import settings
from django.core import validators
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
from django.core.files.uploadedfile import SimpleUploadedFile
from django.template.defaultfilters import slugify
from django.utils import timezone
from django.utils.encoding import (smart_text, smart_bytes, force_text,
python_2_unicode_compatible)
from django.utils.six import string_types, text_type
from filemaker.exceptions import FileMakerValidationError
from filemaker.validators import validate_gtin
try: # pragma: no cover
from functools import total_ordering
except ImportError: # pragma: no cover
# Python < 2.7
def total_ordering(cls): # NOQA
'Class decorator that fills-in missing ordering methods'
convert = {
'__lt__': [('__gt__', lambda self, other: other < self),
('__le__', lambda self, other: not other < self),
('__ge__', lambda self, other: not self < other)],
'__le__': [('__ge__', lambda self, other: other <= self),
('__lt__', lambda self, other: not other <= self),
('__gt__', lambda self, other: not self <= other)],
'__gt__': [('__lt__', lambda self, other: other > self),
('__ge__', lambda self, other: not other > self),
('__le__', lambda self, other: not self > other)],
'__ge__': [('__le__', lambda self, other: other >= self),
('__gt__', lambda self, other: not other >= self),
('__lt__', lambda self, other: not self >= other)]
}
if hasattr(object, '__lt__'):
roots = [op for op in convert
if getattr(cls, op) is not getattr(object, op)]
else:
roots = set(dir(cls)) & set(convert)
assert roots, 'must define at least one ordering operation: < > <= >='
root = max(roots) # prefer __lt __ to __le__ to __gt__ to __ge__
for opname, opfunc in convert[root]:
if opname not in roots:
opfunc.__name__ = opname
opfunc.__doc__ = getattr(int, opname).__doc__
setattr(cls, opname, opfunc)
return cls
try:
from pytz import NonExistentTimeError
except ImportError:
class NonExistentTimeError(Exception): # NOQA
pass
@total_ordering
@python_2_unicode_compatible
[docs]class BaseFileMakerField(object):
'''
The base class that all FileMaker fields should inherit from.
Sub-classes should generally override the coerce method which takes a
value and should return it in the appropriate format.
'''
_value = None
name = None
fm_attr = None
validators = []
min = None
max = None
null_values = [None, '']
fm_null_value = ''
def __init__(self, fm_attr=None, *args, **kwargs):
self.fm_attr = fm_attr
self.null = kwargs.pop('null', False)
self.default = kwargs.pop('default', None)
self._value = self.default
self.min = kwargs.pop('min', self.min)
self.max = kwargs.pop('max', self.max)
for key, value in kwargs.items():
if key == 'fm_attr':
continue
setattr(self, key, value)
def __str__(self):
return smart_text(self.value)
def __repr__(self):
return '<{0}: {1}>'.format(self.__class__.__name__, smart_text(self))
def __eq__(self, other):
return self.value == other.value
def __lt__(self, other):
return self.value < other.value
def __hash__(self):
return '{0}{1}'.format(repr(self), self.name).__hash__()
def _set_value(self, value):
try:
self._value = self._coerce(value)
except (ValueError, TypeError, UnicodeError):
raise FileMakerValidationError(
'"{0}" is an invalid value for {1} ({2})'
.format(value, self.name, self.__class__.__name__),
field=self
)
def _get_value(self):
return self._value
value = property(_get_value, _set_value)
def _coerce(self, value):
if value in self.null_values:
value = None
if not self.null and self.default is not None and value is None:
return self.default
if self.null and value is None:
return None
elif value is None:
raise ValueError('{0} cannot be None'.format(self.name))
value = self.coerce(value)
if self.min is not None and value < self.min:
raise ValueError('{0} must be greater than or equal to {1}.'
.format(self.name, smart_text(self.min)))
if self.max is not None and value > self.max:
raise ValueError('{0} must be less than or equal to {1}.'
.format(self.name, smart_text(self.max)))
if self.validators:
for validator in self.validators:
try:
validator(value)
except ValidationError:
raise FileMakerValidationError(
'"{0}" is an invalid value for {1} ({2})'
.format(value, self.name, self.__class__.__name__),
field=self
)
return value
def coerce(self, value):
raise NotImplementedError()
def to_django(self, *args, **kwargs):
return self.value
def to_filemaker(self):
return smart_text(self.value) if self.value \
is not None else self.fm_null_value
[docs]class UnicodeField(BaseFileMakerField):
'''
Coerces data into a ``unicode`` object on Python 2.x or a ``str`` object on
Python 3.x
'''
def coerce(self, value):
return smart_text(value)
[docs]class CharField(UnicodeField):
'''
An alias for :py:class:`UnicodeField`.
'''
pass
[docs]class TextField(UnicodeField):
'''
An alias for :py:class:`UnicodeField`.
'''
pass
[docs]class BytesField(BaseFileMakerField):
'''
Coerces data into a bytestring instance
'''
def coerce(self, value):
return smart_bytes(value)
[docs]class EmailField(CharField):
'''
A :py:class:`CharField` that vaidates that it's input is a valid email
address.
'''
validators = [validators.validate_email]
[docs]class IPAddressField(CharField):
'''
A :py:class:`CharField` that validates that it's input is a valid IPv4
or IPv6 address.
'''
validators = [validators.validate_ipv46_address]
[docs]class IPv4AddressField(CharField):
'''
A :py:class:`CharField` that validates that it's input is a valid IPv4
address.
'''
validators = [validators.validate_ipv4_address]
[docs]class IPv6AddressField(CharField):
'''
A :py:class:`CharField` that validates that it's input is a valid IPv6
address.
'''
validators = [validators.validate_ipv6_address]
[docs]class IntegerField(BaseFileMakerField):
'''
Coerces data into an integer.
'''
def coerce(self, value):
return int(value)
[docs]class PositiveIntegerField(IntegerField):
'''
An :py:class:`IntegerField` that ensures it's input is 0 or greater.
'''
min = 0
[docs]class CommaSeparatedIntegerField(CharField):
'''
A :py:class:`CharField` that validates a comma separated list of integers
'''
validators = [validators.validate_comma_separated_integer_list]
[docs]class CommaSeparratedIntegerField(CommaSeparatedIntegerField):
'''
Alternate (misspelled) name for :py:class:`CommaSeparatedIntegerField`
.. deprecated:: 0.1.1
This field class is deprecated as of 0.1.1 and will disappear in 0.2.0.
Use :py:class:`CommaSeparatedIntegerField` instead.
'''
def __init__(self, *args, **kwargs):
import warnings
warnings.warn(
message='CommaSeparratedIntegerField is deprecated. Use '
'CommaSeparatedIntegerField.',
category=DeprecationWarning,
)
super(CommaSeparatedIntegerField, self).__init__(*args, **kwargs)
[docs]class FloatField(BaseFileMakerField):
'''
Coerces data into a float.
'''
def coerce(self, value):
return float(value)
[docs]class DecimalField(BaseFileMakerField):
'''
Coerces data into a decimal.Decimal object.
:param decimal_places: (*Optional*) The number of decimal places to
truncate input to.
'''
decimal_places = None
def coerce(self, value):
if not isinstance(value, Decimal):
value = Decimal(smart_text(value))
if self.decimal_places is not None \
and isinstance(self.decimal_places, int):
quant = '0'.join('' for x in range(self.decimal_places + 1))
quant = Decimal('.{0}'.format(quant))
value = value.quantize(quant)
return value
@python_2_unicode_compatible
[docs]class DateTimeField(BaseFileMakerField):
'''
Coerces data into a datetime.datetime instance.
:param strptime: An optional strptime string to use if falling back to the
datetime.datetime.strptime method
'''
combine_datetime = datetime.time.min
strptime = None
def __str__(self):
if self.value:
return smart_text(self.value.isoformat())
return 'None'
def coerce(self, value):
combined = False
if isinstance(value, datetime.datetime):
value = value
elif isinstance(value, datetime.date):
combined = True
value = datetime.datetime.combine(value, self.combine_datetime)
elif isinstance(value, string_types) and self.strptime is not None:
value = datetime.datetime.strptime(value, self.strptime)
elif isinstance(value, string_types) and value.strip():
try:
value = parser.parse(value)
except OverflowError as e:
try:
value = datetime.datetime.strptime(value, '%Y%m%d%H%M%S')
except ValueError:
raise e
elif isinstance(value, (list, tuple)):
value = datetime.datetime(*value)
elif isinstance(value, (int, float)):
value = datetime.datetime.fromtimestamp(value)
else:
raise TypeError('Cannot convert {0} to datetime instance'
.format(type(value)))
if settings.USE_TZ and timezone.is_naive(value):
try:
tz = timezone.get_current_timezone()
if combined:
# If we combined the date with datetime.time.min we
# should adjust by dst to get the correct datetime
value += tz.dst(value)
value = timezone.make_aware(
value, timezone.get_current_timezone())
except NonExistentTimeError:
value = timezone.get_current_timezone().localize(value)
value = timezone.utc.normalize(value)
elif not settings.USE_TZ and timezone.is_aware(value):
value = timezone.make_naive(value, timezone.get_current_timezone())
return value
def to_filemaker(self):
return getattr(self.value, 'isoformat', lambda: '')()
[docs]class DateField(DateTimeField):
'''
Coerces data into a datetime.date instance.
:param strptime: An optional strptime string to use if falling back to the
datetime.datetime.strptime method
'''
def coerce(self, value):
dt = super(DateField, self).coerce(value)
if timezone.is_aware(dt):
dt = timezone.get_current_timezone().normalize(dt)
return dt.date()
[docs]class BooleanField(BaseFileMakerField):
'''
Coerces data into a boolean.
:param map: An optional dictionary mapping that maps values to their
Boolean counterparts.
'''
def __init__(self, fm_attr=None, *args, **kwargs):
self.map = kwargs.pop('map', {})
self.reverse_map = dict((v, k) for k, v in self.map.items())
return super(BooleanField, self).__init__(
fm_attr=fm_attr, *args, **kwargs)
def coerce(self, value):
if value in list(self.map.keys()):
return self.map.get(value)
if isinstance(value, bool):
return value
elif isinstance(value, (int, float)):
return not value == 0
elif isinstance(value, string_types):
value = value.strip().lower()
if value in ['y', 'yes', 'true', 't', '1']:
return True
return False
else:
return bool(value)
def to_filemaker(self):
if self.value in self.reverse_map:
return self.reverse_map.get(self.value)
return force_text(self.value).lower() if self.value \
is not None else self.fm_null_value
[docs]class NullBooleanField(BooleanField):
'''
A BooleanField that also accepts a null value
'''
null = True
def coerce(self, value):
if value is None:
return None
if value in ('None',):
return None
return super(NullBooleanField, self).coerce(value)
[docs]class ListField(BaseFileMakerField):
'''
A field that takes a list of values of other types.
:param base_type: The base field type to use.
'''
base_type = None
def __init__(self, fm_attr=None, *args, **kwargs):
self.base_type = kwargs.pop('base_type', None)
if self.base_type is None:
raise ValueError('You must specify a base_type')
return super(ListField, self)\
.__init__(fm_attr=fm_attr, *args, **kwargs)
def coerce(self, value):
values = []
for val in value:
sub_type = self.base_type()
sub_type.value = val
values.append(sub_type.value)
return values
def to_django(self, *args, **kwargs):
try:
return [v.to_django(*args, **kwargs) for v in self.value]
except AttributeError:
return self.value
def to_filemaker(self):
values = []
for val in self.value:
sub_type = self.base_type()
sub_type.value = val
values.append(sub_type.to_filemaker())
return values
[docs]class ModelField(BaseFileMakerField):
'''
A field that provides a refernce to an instance of another filemaker model,
equivalent to a Django ForeignKey.
:param model: The FileMaker model to reference.
'''
model = None
def __init__(self, fm_attr=None, *args, **kwargs):
self.model = kwargs.pop('model', None)
if self.model is None:
raise ValueError('You must specify a model')
return super(ModelField, self)\
.__init__(fm_attr=fm_attr, *args, **kwargs)
def coerce(self, value):
try:
return self.model(value)
except FileMakerValidationError:
if self.default:
return self.default
if self.null:
return None
raise
def to_django(self, *args, **kwargs):
if self.value:
return self.value.to_django(*args, **kwargs)
return None
def to_filemaker(self):
if self.value:
return self.value.to_filemaker()
return ''
def contribute_to_class(self, cls):
self.model._meta['related'].append((cls, self.name))
[docs]class ToOneField(ModelField):
'''
An alias for :py:class:`ModelField`
'''
pass
[docs]class ModelListField(BaseFileMakerField):
'''
A fields that gives a reference to a list of models, equivalent to a
Django ManyToMany relation.
:param model: The model class to reference.
'''
model = None
def __init__(self, fm_attr=None, *args, **kwargs):
self.model = kwargs.pop('model', None)
if self.model is None:
raise ValueError('You must specify a model')
return super(ModelListField, self)\
.__init__(fm_attr=fm_attr, *args, **kwargs)
def coerce(self, value):
instances = []
for v in value:
instances.append(self.model(v)
if not isinstance(v, self.model)
else v)
return instances
def to_django(self, *args, **kwargs):
return [m.to_django(*args, **kwargs) for m in self.value]
def to_filemaker(self):
return [m.to_filemaker() for m in self.value]
def contribute_to_class(self, cls):
self.model._meta['many_related'].append((cls, self.name))
[docs]class ToManyField(ModelListField):
'''
An alias for :py:class:`ModelListField`.
'''
pass
@python_2_unicode_compatible
[docs]class PercentageField(DecimalField):
'''
A :py:class:`DecimalField` that ensures it's input is between 0 and 100
'''
min = Decimal('0')
max = Decimal('100')
def __str__(self):
return '{0}%'.format(smart_text(self.value))
[docs]class CurrencyField(DecimalField):
'''
A decimal field that uses 2 decimal places and strips off any currency
symbol from the start of it's input.
Has a default minimum value of ``0.00``.
'''
min = Decimal('0.00')
decimal_places = 2
def coerce(self, value):
if isinstance(value, string_types):
symbols = [
'¤', '؋', '฿', 'B/.', 'Bs.', 'Bs.F.', 'GH¢', '¢', 'Ch.', '₡',
'D', 'ден', 'دج', '.د.ب', 'د.ع', 'د.ك', 'ل.د', 'дин', 'د.ت',
'د.م.', 'د.إ', '\$', '[a-zA-z]{1,3}\$', '\$[a-zA-Z]{1,3}',
'元', '圓', '元', '圓', '₫', '€', '€', 'ƒ', 'Afl.', 'NAƒ',
'FCFA', '₣', 'G₣', 'S₣', 'Fr.', '₲', '₴', '₭', 'Kč', 'Íkr',
'K.D.', 'ლ', 'm.', '₥', '₦', 'Nu.', '₱', '£', '₤',
'[a-zA-Z]{1,2}[£₤]', 'ج.م.', 'Pt.', 'ريال', 'ر.ع.', 'ر.ق',
'ر.س', 'ریال', '៛', '₹', '₹', '₨', '₪', 'KSh', 'Sh.So.',
'S/.', 'лв', 'сом', '৳', '₸', '₮', 'VT', '₩', '¥', '円', '圓',
'元', '圆', 'zł', '₳', '₢', '₰', '₯', '₠', 'ƒ', '₣', '₤',
'Kčs', 'ℳ', '₧', 'ℛℳ', '₷', '₶', '[a-zA-Z]{1,4}',
]
value = re.sub(
r'^({0})'.format(r'|'.join(symbols)), '', value.strip()
).strip()
return super(CurrencyField, self).coerce(value)
[docs]class SlugField(CharField):
'''
A :py:class:`CharField` that validates it's input is a valid slug.
Will automatically slugify it's input, by default.
Can also be passed a specific slugify function.
.. note::
If the custom ``slugify`` function would create a slug that would fail
a test by ``django.core.validators.validate_slug`` it may be wise to
pass in a different or empty ``validators`` list.
:param auto: Whether to slugify input. Defaults to ``True``.
:param slugify: The slugify function to use. Defaults to
``django.template.defaultfilters.slugify``.
'''
validators = [validators.validate_slug]
def __init__(self, fm_attr=None, *args, **kwargs):
self.slugify = kwargs.pop('slugify', slugify)
self.auto = kwargs.pop('auto', True)
return super(SlugField, self)\
.__init__(fm_attr=fm_attr, *args, **kwargs)
def coerce(self, value):
value = super(SlugField, self).coerce(value)
if self.auto:
value = self.slugify(value)
return value
[docs]class GTINField(CharField):
'''
A :py:class:`CharField` that validates it's input is a valid
`GTIN <https://en.wikipedia.org/wiki/Global_Trade_Item_Number>`_.
'''
validators = [validate_gtin]
[docs]class URLField(CharField):
'''
A :py:class:`CharField` that validates it's input is a valid URL.
'''
validators = [validators.URLValidator()]
[docs]class FileField(BaseFileMakerField):
'''
A field that downloads file data (e.g. from the FileMaker web interface).
The file will be saved with a filename that is the combination of the
hash of it's contents, and the extension associated with the mimetype it
was served with.
Can be given an optional ``base_url`` with which the URL received from
FileMaker will be joined.
:param retries: The number of retries to make when downloading the file in
case of errors. Defaults to ``5``.
:param base_url: The URL with which to combine the url received from
FileMaker, empty by default.
:param storage: The Django storage class to use when saving the file.
Defaults to the default storage class.
'''
retries = 5
base_url = ''
storage = default_storage
def __init__(self, fm_attr=None, *args, **kwargs):
self.base_url = urlobject.URLObject(kwargs.pop('base_url', ''))
return super(FileField, self)\
.__init__(fm_attr=fm_attr, *args, **kwargs)
def _get_http(self, url):
try:
r = requests.get(url.without_auth(), auth=url.auth)
r.raise_for_status()
return r.content, \
r.headers.get('Content-Type', '').split(';')[0]
except requests.RequestException:
return None, None
def _get_file(self, url):
content, mime = None, None
for i in range(self.retries):
if url.scheme in ('http', 'https'):
get = self._get_http
else:
raise FileMakerValidationError(
'Unable to obtain file via "{0}"'.format(url.scheme))
content, mime = get(url)
if content is not None and mime is not None:
break
if content is None or mime is None:
raise FileMakerValidationError(
'Could not get file from: {0}'.format(url))
if mime in ('image/jpeg', 'image/jpe', 'image/jpg'):
mime = 'image/jpg'
fname = '{0}{1}'.format(
hashlib.md5(smart_bytes(content)).hexdigest()[:20],
mimetypes.guess_extension(mime, strict=False),
)
return {'filename': fname, 'content': content, 'content-type': mime}
def coerce(self, value):
url = urlobject.URLObject(smart_text(value or ''))
try:
if not url.scheme:
url = url.with_scheme(self.base_url.scheme or '')
if not url.hostname:
url = url.with_hostname(self.base_url.hostname or '')
if url.auth == (None, None) \
and not self.base_url.auth == (None, None):
url = url.with_auth(*self.base_url.auth)
except (TypeError, ValueError): # pragma: no cover
raise FileMakerValidationError('Could not determine file url.')
return self._get_file(url)
def to_django(self, *args, **kwargs):
return SimpleUploadedFile.from_dict(self.value) if self.value else None
def to_filemaker(self):
return self.storage.url(self.value['filename']) \
if self.value is not None and self.value.get('filename', None) \
else self.fm_null_value
[docs]class ImageField(FileField):
'''
A :py:class:`FileField` that expects the mimetype of the received file to
be ``image/*``.
'''
def _get_file(self, url):
f = super(ImageField, self)._get_file(url)
if not f.content_type.split('/')[0] == 'image':
raise FileMakerValidationError(
'"{0}" is not a valid image type.'.format(f.content_type))
return f
[docs]class UploadedFileField(BaseFileMakerField):
'''
Takes the path of a file that has already been uploaded to storage and
generates a File instance from it.
:param storage: An instance of the storage class to use
'''
storage = default_storage
def coerce(self, value):
value = re.sub(
r'^[\s\/\.]+', '', text_type(urlobject.URLObject(value).path))
try:
f = self.storage.open(value)
except (Exception, EnvironmentError):
raise FileMakerValidationError(
'Could not open file "{0}".'.format(value))
else:
return {'file': f, 'filename': f.name}
def to_django(self, *args, **kwargs):
return self.value['file'] if self.value else None
def to_filemaker(self):
return self.storage.url(self.value['filename']) \
if self.value else self.fm_null_value