# -*- coding: utf-8 -*-
from __future__ import unicode_literals
import copy
import re
import requests
from django.http import QueryDict
from django.utils import six
from urlobject import URLObject
from filemaker.exceptions import FileMakerConnectionError
from filemaker.parser import FMXMLObject
OPERATORS = {
'exact': 'eq',
'contains': 'cn',
'startswith': 'bw',
'endswith': 'ew',
'gt': 'gt',
'gte': 'gte',
'lt': 'lt',
'lte': 'lte',
'neq': 'neq',
}
[docs]class RawManager(object):
'''
The raw manager allows you to query the FileMaker web interface.
Most manager methods (the exceptions being the committing methods;
``find``, ``find_all``, ``edit``, ``new``, and ``delete``) are chainable,
enabling usage like
::
manager = RawManager(...)
manager = manager.filter(field=value).add_sort_param('some_field')
results = manager.find_all()
'''
[docs] def __init__(self, url, db, layout, response_layout=None, **kwargs):
'''
:param url: The URL to access the FileMaker server. This should contain
any authorization credentials. If a path is not provided (e.g. no
trailing slash, like ``http://username:password@192.168.1.2``) then
the default path of ``/fmi/xml/fmresultset.xml`` will be used.
:param db: The database name to access (sets the ``-db`` parameter).
:param layout: The layout to use (sets the ``-lay`` parameter).
:param response_layout: (*Optional*) The layout to use (sets the
``-lay.response`` parameter).
'''
self.url = URLObject(url).without_auth()
self.url = self.url.with_path(
self.url.path or '/fmi/xml/fmresultset.xml')
self.auth = URLObject(url).auth
self.params = QueryDict('', mutable=True)
self.dbparams = QueryDict('', mutable=True)
self.dbparams.update({
'-db': db,
'-lay': layout,
})
if response_layout:
self.dbparams['-lay.response'] = response_layout
self.params['-max'] = '50'
def __repr__(self):
return '<RawManager: {0} {1} {2}>'.format(
self.url, self.dbparams, self.params)
def _clone(self):
return copy.copy(self)
[docs] def set_script(self, name, option=None):
'''
Sets the name of the filemaker script to use
:param name: The name of the script to use.
:param option: (*Optional*) Can be one of ``presort`` or ``prefind``.
'''
mgr = self._clone()
key = '-script'
if option in ('prefind', 'presort'):
key = '{0}.{1}'.format(key, option)
mgr.params[key] = name
return mgr
[docs] def set_record_id(self, recid):
'''
Sets the ``-recid`` parameter.
:param recid: The record ID to set.
'''
mgr = self._clone()
mgr.params['-recid'] = recid
return mgr
[docs] def set_modifier_id(self, modid):
'''
Sets the ``-modid`` parameter.
:param modid: The modifier ID to set.
'''
mgr = self._clone()
mgr.params['-modid'] = modid
return mgr
[docs] def set_logical_operator(self, op):
'''
Set the logical operator to be used for this query using the ``-op``
parameter.
:param op: Must be one of ``and`` or ``or``.
'''
mgr = self._clone()
if op in ('and', 'or'):
mgr.params['-lop'] = op
return mgr
[docs] def set_group_size(self, max):
'''
Set the group size to return from FileMaker using the ``-max``.
This is defaulted to 50 when the manager is initialized.
:param integer max: The number of records to return.
'''
self.params['-max'] = max
return self
[docs] def set_skip_records(self, skip):
'''
The number of records to skip when retrieving records from FileMaker
using the ``-skip`` parameter.
:param integer skip: The number of records to skip.
'''
self.params['-skip'] = skip
return self
[docs] def add_db_param(self, field, value, op=None):
'''
Adds an arbitrary parameter to the query to be performed. An optional
operator parameter may be specified which will add an additional field
to the parameters. e.g. ``.add_db_param('foo', 'bar')`` sets the
parameter ``...&foo=bar&...``, ``.add_db_param('foo', 'bar', 'gt')``
sets ``...&foo=bar&foo.op=gt&...``.
:param field: The field to query on.
:param value: The query value.
:param op: (*Optional*) The operator to use for this query.
'''
mgr = self._clone()
mgr.params.appendlist(field, value)
if op:
mgr.params.appendlist('{0}.op'.format(field), op)
return mgr
[docs] def add_sort_param(self, field, order='ascend', priority=0):
'''
Add a sort field to the query.
:param field: The field to sort on.
:param order: (*Optional*, defaults to ``ascend``) The direction to
sort, one of ``ascending`` or ``descending``.
:param priority: (*Optional*, defaults to ``0``) the order to apply
this sort in if multiple sort fields are specified.
'''
mgr = self._clone()
mgr.params['-sortfield.{0}'.format(priority)] = field
mgr.params['-sortorder.{0}'.format(priority)] = order
return mgr
[docs] def find(self, **kwargs):
'''
Performs the -find command. This method internally calls ``_commit``
and is not chainable.
:param \**kwargs: Any additional fields to search on, which will be
passed directly into the URL parameters.
:rtype: :py:class:`filemaker.parser.FMXMLObject`
'''
self.params.update(kwargs)
return self._commit('find')
[docs] def find_all(self, **kwargs):
'''
Performs the -findall command to return all records. This method
internally calls ``_commit`` and is not chainable.
:param \**kwargs: Any additional URL parameters.
:rtype: :py:class:`filemaker.parser.FMXMLObject`
'''
self.params.update(kwargs)
return self._commit('findall')
[docs] def edit(self, **kwargs):
'''
Updates a record using the ``-edit`` command. This method
internally calls ``_commit`` and is not chainable.
You should have either called the :py:meth:`set_record_id` and/or
:py:meth:`set_modifier_id` methods on the manager, or passed in
``RECORDID`` or ``MODID`` as params.
:param \**kwargs: Any additional parameters to pass into the URL.
:rtype: :py:class:`filemaker.parser.FMXMLObject`
'''
self.params.update(kwargs)
return self._commit('edit')
[docs] def new(self, **kwargs):
'''
Creates a new record using the ``-new`` command. This method
internally calls ``_commit`` and is not chainable.
:param \**kwargs: Any additional parameters to pass into the URL.
:rtype: :py:class:`filemaker.parser.FMXMLObject`
'''
self.params.update(kwargs)
return self._commit('new')
[docs] def delete(self, **kwargs):
'''
Deletes a record using the ``-delete`` command. This method
internally calls ``_commit`` and is not chainable.
You should have either called the :py:meth:`set_record_id` and/or
:py:meth:`set_modifier_id` methods on the manager, or passed in
``RECORDID`` or ``MODID`` as params.
:param \**kwargs: Any additional parameters to pass into the URL.
:rtype: :py:class:`filemaker.parser.FMXMLObject`
'''
self.params.update(kwargs)
return self._commit('delete')
def _commit(self, action):
if 'RECORDID' in self.params and not '-recid' in self.params:
self.params['-recid'] = self.params['RECORDID']
del self.params['RECORDID']
if 'MODID' in self.params and not '-modid' in self.params:
self.params['-modid'] = self.params['MODID']
del self.params['MODID']
data = '&'.join([
self.dbparams.urlencode(),
self.params.urlencode(),
'-{0}'.format(action),
])
try:
resp = requests.post(self.url, auth=self.auth, data=data)
resp.raise_for_status()
except requests.exceptions.RequestException as e:
raise FileMakerConnectionError(e)
return FMXMLObject(resp.content)
[docs]class Manager(RawManager):
'''
A manager for use with :py:class:`filemaker.base.FileMakerModel` classes.
Inherits from the :py:class:`RawManager`, but adds some conveniences and
field mapping methods for use with
:py:class:`filemaker.base.FileMakerModel` sub-classes.
This manager can be treated as an iterator returning instances of the
relavent :py:class:`filemaker.base.FileMakerModel` sub-class returned from
the FileMaker server. It also supports slicing etc., although negative
indexing is unsupported.
'''
[docs] def __init__(self, cls):
'''
:param cls: The :py:class:`filemaker.base.FileMakerModel` sub-class to
use this manager with. It is expected that the model ``meta``
dictionary will have a ``connection`` key to a dictionary with
values for ``url``, ``db``, and ``layout``.
'''
self.cls = cls
super(Manager, self).__init__(**self.cls._meta.get('connection'))
self._result_cache = None
self._fm_data = None
def __iter__(self):
return self.iterator()
def iterator(self):
if not self._result_cache:
self._result_cache = \
self.preprocess_resultset(self._get_fm_data().resultset)
for result in self._result_cache:
yield self.cls(result)
def __len__(self):
return len(self._get_fm_data().resultset)
def __getitem__(self, k):
mgr = self
if not isinstance(k, (slice,) + six.integer_types):
raise TypeError
assert ((not isinstance(k, slice) and (k >= 0))
or (isinstance(k, slice) and (k.start is None or k.start >= 0)
and (k.stop is None or k.stop >= 0))), \
'Negative indexing is not supported.'
if isinstance(k, slice):
if k.start:
mgr = mgr.set_skip_records(k.start)
if k.stop:
mgr = mgr.set_group_size(k.stop - (k.start or 0))
return list(mgr)[k]
def __repr__(self):
return '<{0} query with {1} records...>'.format(
self.cls.__name__, len(self))
def _get_fm_data(self):
if self._fm_data is None:
self._fm_data = self.find()
return self._fm_data
def _clone(self):
mgr = super(Manager, self)._clone()
mgr._result_cache = None
mgr._fm_data = None
return mgr
def _resolve_fm_field(self, field):
from filemaker.fields import ModelField
parts = field.split('__')
fm_attr_path = []
klass = self.cls
resolved_field = None
for part in parts:
try:
klass = resolved_field.model if resolved_field else self.cls
except AttributeError:
raise ValueError('Cound not resolve field: {0}'.format(field))
resolved_field = klass._fields.get(part)
if resolved_field is None:
raise ValueError('Cound not resolve field: {0}'.format(field))
path = resolved_field.fm_attr.replace('.', '::')
if not path == '+self' and not isinstance(
resolved_field, ModelField):
fm_attr_path.append(path)
return '::'.join(fm_attr_path)
[docs] def preprocess_resultset(self, resultset):
'''
This is a hook you can override on a manager to pre-process a resultset
from FileMaker before the data is converted into model instances.
:param resultset: The ``resultset`` attribute of the
:py:class:`filemaker.parser.FMXMLObject` returned from FileMaker
'''
return resultset
[docs] def all(self):
'''
A no-op returning a clone of the current manager
'''
return self._clone()
[docs] def filter(self, **kwargs):
'''
Filter the queryset by model fields. Model field names are passed in as
arguments rather than FileMaker fields.
Queries spanning relationships can be made using a ``__``, and
operators can be specified at the end of the query. e.g. Given a model:
::
class Foo(FileMakerModel):
beans = fields.IntegerField('FM_Beans')
meta = {
'abstract': True,
...
}
class Bar(FileMakerModel):
foo = fields.ModelField('BAR_Foo', model=Foo)
num = models.IntegerField('FM_Num'))
meta = {
'connection': {...},
...
}
To find all instances of a ``Bar`` with ``num == 4``:
::
Bar.objects.filter(num=4)
To find all instances of ``Bar`` with ``num < 4``:
::
Bar.objects.filter(num__lt=4)
To Find all instance of ``Bar`` with a ``Foo`` with ``beans == 4``:
::
Bar.objects.filter(foo__beans=4)
To Find all instance of ``Bar`` with a ``Foo`` with ``beans > 4``:
::
Bar.objects.filter(foo__beans__gt=4)
The ``filter`` method is also chainable so you can do:
::
Bar.objects.filter(num=4).filter(foo__beans=4)
:param \**kwargs: The fields and values to filter on.
'''
mgr = self
for k, v in kwargs.items():
operator = 'eq'
for op, code in OPERATORS.items():
if k.endswith('__{0}'.format(op)):
k = re.sub(r'__{0}$'.format(op), '', k)
operator = code
break
try:
mgr = mgr.add_db_param(
self._resolve_fm_field(k), v, op=operator)
except (KeyError, ValueError):
raise ValueError('Invalid filter argument: {0}'.format(k))
return mgr
[docs] def get(self, **kwargs):
'''
Returns the first item found by filtering the queryset by ``**kwargs``.
Will raise the ``DoesNotExist`` exception on the managers model class
if no items are found, however, unlike the Django ORM, will silently
return the first result if multiple results are found.
:param \**kwargs: Field and value queries to be passed to
:py:meth:`filter`
'''
try:
return self.filter(**kwargs)[0]
except IndexError:
raise self.cls.DoesNotExist('Could not find item in FileMaker')
[docs] def order_by(self, *args):
'''
Add an ordering to the queryset with respect to a field.
If the field name is prepended by a ``-`` that field will be sorted in
reverse. Multiple fields can be specified.
This method is also chainable so you can do, e.g.:
::
Foo.objects.filter(foo='bar').order_by('qux').filter(baz=1)
:param \*args: The field names to order by.
'''
mgr = self._clone()
for key in list(mgr.params.keys()):
if key.startswith('-sortfield') or key.startswith('-sortorder'):
mgr.params.pop(key)
i = 0
for arg in args:
if arg.startswith('-'):
mgr = mgr.add_sort_param(
mgr._resolve_fm_field(arg[1:]), 'descend', i)
else:
mgr = mgr.add_sort_param(
mgr._resolve_fm_field(arg), 'ascend', i)
i += 1
return mgr
[docs] def count(self):
'''
Returns the number of results returned from FileMaker for this query.
'''
return len(self)