├── test_project ├── __init__.py ├── urls.py ├── wsgi.py └── settings.py ├── filemaker ├── models.py ├── validators.py ├── __init__.py ├── utils.py ├── conf.py ├── parser.py ├── base.py ├── exceptions.py ├── manager.py ├── fields.py └── tests.py ├── ci_requirements.txt ├── test_xml ├── test_broken_xml.xml ├── test_invalid_xml.xml ├── README.rst ├── test_empty_xml.xml ├── test_xml.xml └── test_error_xml.xml ├── docs ├── exceptions.rst ├── managers.rst ├── index.rst ├── fields.rst ├── models.rst ├── Makefile ├── make.bat └── conf.py ├── requirements.txt ├── .gitignore ├── MANIFEST.in ├── .coveragerc ├── manage.py ├── runtests.py ├── .travis.yml ├── tox.ini ├── LICENSE ├── filemaker.xsl ├── setup.py └── README.rst /test_project/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /filemaker/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /ci_requirements.txt: -------------------------------------------------------------------------------- 1 | django-appconf 2 | lxml 3 | python-dateutil 4 | pytz 5 | requests 6 | urlobject 7 | -------------------------------------------------------------------------------- /test_xml/test_broken_xml.xml: -------------------------------------------------------------------------------- 1 | >><<>>~@~:{:Wfdnjwbefuvt 4 | -------------------------------------------------------------------------------- /docs/exceptions.rst: -------------------------------------------------------------------------------- 1 | Exceptions 2 | ========== 3 | 4 | .. automodule:: filemaker.exceptions 5 | :members: 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django-appconf 2 | django>=1.4.5 3 | lxml 4 | python-dateutil 5 | pytz 6 | requests 7 | urlobject 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg 2 | *.egg-info 3 | *.py[co] 4 | *~ 5 | .#* 6 | .*.swp 7 | /build 8 | /dist 9 | [#]*# 10 | docs/_build 11 | .coverage 12 | .tox 13 | -------------------------------------------------------------------------------- /test_xml/test_invalid_xml.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 404 4 | 5 | 6 |

404 Not Found

7 | 8 | 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.rst 2 | include CONTRIBUTING.rst 3 | include LICENSE 4 | include README.rst 5 | include requirements.txt 6 | recursive-include filemaker *.html *.png *.gif *js *jpg *jpeg *svg *py 7 | recursive-exclude filemaker *.pyc 8 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | omit = 3 | *tests.py 4 | filemaker/models.py 5 | include = filemaker/* 6 | 7 | [report] 8 | exclude_lines = 9 | pragma: no cover 10 | def __repr__ 11 | raise NotImplementedError 12 | if __name__ == .__main__.: 13 | -------------------------------------------------------------------------------- /test_xml/README.rst: -------------------------------------------------------------------------------- 1 | Test XML files 2 | ============== 3 | 4 | The test XML files used here are based on sample data found in the `freely 5 | available Filemaker® Web Publishing with XML documentation [pdf] 6 | `_. 7 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /filemaker/validators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.core.validators import RegexValidator 5 | 6 | 7 | gtin_re = r'^[0-9]{6,8}$|^[0-9]{10}$|^[0-9]{12}$|^[0-9]{13}$|^[0-9]{14,}$' 8 | 9 | validate_gtin = \ 10 | RegexValidator(gtin_re, 'Please enter a valid GTIN/ISBN/EAN/UPC code.') 11 | -------------------------------------------------------------------------------- /filemaker/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from distutils import version 5 | 6 | 7 | __version__ = '0.2.2' 8 | version_info = version.StrictVersion(__version__).version 9 | 10 | 11 | from filemaker.base import FileMakerModel # NOQA 12 | from filemaker.exceptions import * # NOQA 13 | from filemaker import fields # NOQA 14 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import os 5 | import sys 6 | 7 | 8 | os.environ['PYTHONPATH'] = os.path.dirname(__file__) 9 | os.environ['DJANGO_SETTINGS_MODULE'] = 'test_project.settings' 10 | 11 | 12 | def runtests(): 13 | from django.conf import settings 14 | from django.test.utils import get_runner 15 | test_runner = get_runner(settings)() 16 | failures = test_runner.run_tests(['filemaker']) 17 | sys.exit(failures) 18 | 19 | 20 | if __name__ == '__main__': 21 | runtests() 22 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.3" 5 | - "pypy" 6 | env: 7 | - DJANGO=">=1.4.5,<1.5" 8 | - DJANGO=">=1.5,<1.6" 9 | - DJANGO="" 10 | install: 11 | - "pip install -r ci_requirements.txt --use-mirrors" 12 | - "pip install django${DJANGO}" 13 | - "pip install mock httpretty==0.6.3" 14 | before_script: 15 | - "pip install python-coveralls coverage" 16 | script: 17 | - "coverage run runtests.py" 18 | after_success: 19 | - "coveralls" 20 | notifications: 21 | email: false 22 | matrix: 23 | exclude: 24 | - python: "3.3" 25 | env: DJANGO=">=1.4.5,<1.5" 26 | -------------------------------------------------------------------------------- /test_project/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import patterns, include, url 2 | 3 | # Uncomment the next two lines to enable the admin: 4 | # from django.contrib import admin 5 | # admin.autodiscover() 6 | 7 | urlpatterns = patterns('', 8 | # Examples: 9 | # url(r'^$', 'test_project.views.home', name='home'), 10 | # url(r'^test_project/', include('test_project.foo.urls')), 11 | 12 | # Uncomment the admin/doc line below to enable admin documentation: 13 | # url(r'^admin/doc/', include('django.contrib.admindocs.urls')), 14 | 15 | # Uncomment the next line to enable the admin: 16 | # url(r'^admin/', include(admin.site.urls)), 17 | ) 18 | -------------------------------------------------------------------------------- /docs/managers.rst: -------------------------------------------------------------------------------- 1 | Managers 2 | ======== 3 | 4 | .. py:module:: filemaker.manager 5 | 6 | The raw FileMaker manager 7 | ------------------------- 8 | 9 | .. autoclass:: filemaker.manager.RawManager 10 | :special-members: __init__ 11 | :members: 12 | 13 | 14 | .. py:currentmodule:: filemaker.parser 15 | 16 | The ``FMXMLObject`` response 17 | ---------------------------- 18 | 19 | .. autoclass:: FMXMLObject 20 | :undoc-members: 21 | 22 | .. autoclass:: FMDocument 23 | 24 | .. py:currentmodule:: filemaker.manager 25 | 26 | The FileMakerModel Manager 27 | -------------------------- 28 | 29 | .. autoclass:: filemaker.manager.Manager 30 | :special-members: __init__ 31 | :members: 32 | 33 | -------------------------------------------------------------------------------- /filemaker/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.utils.importlib import import_module 5 | from django.utils.six import string_types 6 | 7 | from filemaker.conf import settings 8 | 9 | 10 | def import_string(s): 11 | mod, attr = s.rsplit('.', 1) 12 | return getattr(import_module(mod), attr) 13 | 14 | 15 | def get_field_class(cls): 16 | ''' 17 | Returns the FileMaker class that matches the given django field class 18 | ''' 19 | if isinstance(cls, string_types): 20 | cls = import_string(cls) 21 | elif not isinstance(cls, type): 22 | cls = type(cls) 23 | fm_cls = settings.FILEMAKER_DJANGO_FIELD_MAP.get(cls, None) 24 | if isinstance(fm_cls, string_types): 25 | fm_cls = import_string(fm_cls) 26 | return fm_cls 27 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py27-dj14, py27-dj15, py33, py33-dj15, pypy, pypy-dj14, pypy-dj15, docs 3 | 4 | [testenv] 5 | PYTHONPATH = {toxinidir}:{toxinidir}/filemaker 6 | commands = python setup.py test 7 | install_command = pip install {opts} {packages} 8 | deps = 9 | -r{toxinidir}/ci_requirements.txt 10 | django 11 | 12 | [testenv:docs] 13 | changedir = docs 14 | deps = 15 | -r{toxinidir}/requirements.txt 16 | sphinx 17 | commands = 18 | make html 19 | 20 | [testenv:py27-dj14] 21 | basepython=python2.7 22 | deps = 23 | -r{toxinidir}/ci_requirements.txt 24 | django>=1.4.5,<1.5 25 | 26 | [testenv:pypy-dj14] 27 | basepython=pypy 28 | deps = 29 | -r{toxinidir}/ci_requirements.txt 30 | django>=1.4.5,<1.5 31 | 32 | [testenv:py27-dj15] 33 | basepython=python2.7 34 | deps = 35 | -r{toxinidir}/ci_requirements.txt 36 | django>=1.5,<1.6 37 | 38 | [testenv:py33-dj15] 39 | basepython=python3.3 40 | deps = 41 | -r{toxinidir}/ci_requirements.txt 42 | django>=1.5,<1.6 43 | 44 | [testenv:pypy-dj15] 45 | basepython=pypy 46 | deps = 47 | -r{toxinidir}/ci_requirements.txt 48 | django>=1.5,<1.6 49 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013, Titan Entertainment Group 2 | Copyright (c) 2013, Luke Pomfrey 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, 9 | this list of conditions and the following disclaimer. 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 15 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 16 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 17 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 18 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 20 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 21 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 22 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 23 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 24 | -------------------------------------------------------------------------------- /test_project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for test_project project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks 19 | # if running multiple sites in the same mod_wsgi process. To fix this, use 20 | # mod_wsgi daemon mode with each site in its own daemon process, or use 21 | # os.environ["DJANGO_SETTINGS_MODULE"] = "test_project.settings" 22 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "test_project.settings") 23 | 24 | # This application object is used by any WSGI server configured to use this 25 | # file. This includes Django's development server, if the WSGI_APPLICATION 26 | # setting points here. 27 | from django.core.wsgi import get_wsgi_application 28 | application = get_wsgi_application() 29 | 30 | # Apply WSGI middleware here. 31 | # from helloworld.wsgi import HelloWorldApplication 32 | # application = HelloWorldApplication(application) 33 | -------------------------------------------------------------------------------- /filemaker.xsl: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /test_xml/test_empty_xml.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import re 6 | import sys 7 | from setuptools import setup, find_packages 8 | 9 | 10 | def get_version(package): 11 | ''' 12 | Return package version as listed in `__version__` in `init.py`. 13 | ''' 14 | init_py = open(os.path.join(package, '__init__.py')).read() 15 | return re.search( 16 | '^__version__ = [\'"]([^\'"]+)[\'"]', init_py, re.MULTILINE 17 | ).group(1) 18 | 19 | 20 | version = get_version('filemaker') 21 | 22 | 23 | if sys.argv[-1] == 'publish': 24 | os.system('python setup.py sdist upload') 25 | args = {'version': version} 26 | print('You probably want to also tag the version now:') 27 | print(' git tag -a release/{version} -m \'version {version}\''.format( 28 | **args)) 29 | print(' git push --tags') 30 | sys.exit() 31 | 32 | 33 | setup( 34 | name='django-filemaker', 35 | version=version, 36 | url='http://github.com/TitanEntertainmentGroup/django-filemaker', 37 | license='BSD', 38 | description='FileMaker access and integration with Django', 39 | author='Luke Pomfrey', 40 | author_email='luke.pomfrey@titanemail.com', 41 | packages=find_packages(exclude='test_project'), 42 | install_requires=[ 43 | 'django', 44 | 'django-appconf', 45 | 'lxml', 46 | 'python-dateutil', 47 | 'pytz', 48 | 'requests', 49 | 'urlobject', 50 | ], 51 | tests_require=['mock', 'httpretty'], 52 | test_suite='runtests.runtests', 53 | classifiers=[ 54 | 'Development Status :: 4 - Beta', 55 | 'Environment :: Web Environment', 56 | 'Framework :: Django', 57 | 'Intended Audience :: Developers', 58 | 'License :: OSI Approved :: BSD License', 59 | 'Operating System :: OS Independent', 60 | 'Programming Language :: Python', 61 | 'Topic :: Internet :: WWW/HTTP' 62 | ] 63 | ) 64 | -------------------------------------------------------------------------------- /test_xml/test_xml.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Spring in Giverny 3 21 | 22 | 23 | Claude Monet 24 | 25 | 26 | 27 | 28 | 29 | 30 | 19 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /test_xml/test_error_xml.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | Spring in Giverny 3 21 | 22 | 23 | Claude Monet 24 | 25 | 26 | 27 | 28 | 29 | 30 | 19 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /filemaker/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from appconf import AppConf 5 | from django.conf import settings # NOQA 6 | from django.utils.importlib import import_module 7 | from django.utils.six import string_types 8 | 9 | 10 | class FilemakerAppConf(AppConf): 11 | 12 | from django.db.models import fields 13 | 14 | DJANGO_FIELD_MAP = { 15 | fields.BooleanField: 'filemaker.fields.BooleanField', 16 | fields.CharField: 'filemaker.fields.CharField', 17 | fields.CommaSeparatedIntegerField: 18 | 'filemaker.fields.CommaSeparatedIntegerField', 19 | fields.DateField: 'filemaker.fields.DateField', 20 | fields.DateTimeField: 'filemaker.fields.DateTimeField', 21 | fields.DecimalField: 'filemaker.fields.DecimalField', 22 | fields.EmailField: 'filemaker.fields.EmailField', 23 | fields.FilePathField: 'filemaker.fields.CharField', 24 | fields.FloatField: 'filemaker.fields.FloatField', 25 | fields.IntegerField: 'filemaker.fields.IntegerField', 26 | fields.BigIntegerField: 'filemaker.fields.IntegerField', 27 | fields.IPAddressField: 'filemaker.fields.IPAddressField', 28 | fields.GenericIPAddressField: 'filemaker.fields.IPAddressField', 29 | fields.NullBooleanField: 'filemaker.fields.NullBooleanField', 30 | fields.PositiveIntegerField: 'filemaker.fields.PositiveIntegerField', 31 | fields.PositiveSmallIntegerField: 32 | 'filemaker.fields.PositiveIntegerField', 33 | fields.SlugField: 'filemaker.fields.SlugField', 34 | fields.SmallIntegerField: 'filemaker.fields.IntegerField', 35 | fields.TextField: 'filemaker.fields.TextField', 36 | fields.TimeField: 'filemaker.fields.DateTimeField', 37 | fields.URLField: 'filemaker.fields.URLField', 38 | } 39 | DJANGO_FIELD_MAP_OVERRIDES = {} 40 | 41 | class Meta: 42 | prefix = 'filemaker' 43 | 44 | def _import(self, s): # pragma: no cover 45 | mod, attr = s.rsplit('.', 1) 46 | return getattr(import_module(mod), attr) 47 | 48 | def configure(self): 49 | overrides = self.configured_data.get('DJANGO_FIELD_MAP_OVERRIDES') 50 | for k, v in overrides: # pragma: no cover 51 | if isinstance(k, string_types): 52 | overrides[self._import(k)] = v 53 | del overrides[k] 54 | self.configured_data['DJANGO_FIELD_MAP'].update(overrides) 55 | return self.configured_data 56 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-filemaker documentation master file, created by 2 | sphinx-quickstart on Fri Jun 28 12:48:27 2013. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | django-filemaker documentation 7 | ============================== 8 | 9 | Pythonic FileMaker® access and FileMaker layout to Django model mapping. 10 | 11 | ``django-filemaker`` provides a framework for basic interaction with a 12 | FileMaker® database via its web XML publisher interface, and a simple model 13 | style formulation to retrieve objects from FileMaker® and map them into Django 14 | model instances. 15 | 16 | 17 | Quickstart 18 | ---------- 19 | 20 | Create a ``FileMakerModel``: 21 | 22 | :: 23 | 24 | from django.contrib.flatpages.models import FlatPage 25 | from django.contrib.sites.models import Site 26 | from filemaker import fields, FileMakerModel 27 | 28 | 29 | class FileMakerFlatPage(FileMakerModel): 30 | 31 | # The first argument to a FileMaker field should be the field name for 32 | # that item on the FileMaker layout 33 | pk = fields.IntegerField('zpkFlatpageID') 34 | url = fields.CharField('Url_FileMaker_Field') 35 | title = fields.CharField('Title_FileMaker_Field') 36 | content = fields.CharField('Content_FileMaker_Field') 37 | # You can pass in a default value to any field 38 | template_name = fields.CharField( 39 | 'Template_Name_Field', default='flatpages/default.html') 40 | registration_required = fields.BooleanField( 41 | 'Registration_Required_Field', default=False) 42 | sites = fields.ModelListField('SITES', model=FileMakerSite) 43 | 44 | meta = { 45 | 'connection': { 46 | 'url': 'http://user:password@example.com/', 47 | 'db': 'Db_Name', 48 | 'layout': 'Layout_Name', 49 | }, 50 | 'model': FlatPage, 51 | 'pk_name': 'pk', 52 | } 53 | 54 | class FileMakerSite(FileMakerModel): 55 | # On related fields we specify the relative field to the field name 56 | # specified on the calling model (FileMakerFlatPage), unless the 57 | # calling model uses the special '+self' value which passes the layout 58 | # of that model to the sub model 59 | domain = fields.CharField('Domain_field') 60 | name = fields.CharField('Name_Field') 61 | 62 | meta = { 63 | 'model': Site, 64 | # abstract here means it is a child of an actual FileMaker layout 65 | 'abstract': True, 66 | } 67 | 68 | 69 | Query FileMaker for instances of your model, and convert them to django 70 | instances using the ``to_django`` method: 71 | 72 | :: 73 | 74 | >>> # The Django style methods will convert field names 75 | >>> FlatPage.objects.count() == 0 76 | True 77 | >>> fm_page = FileMakerFlatPage.objects.get(pk=1) 78 | >>> fm_page.to_django() 79 | 80 | >>> FlatPage.objects.count() == 1 81 | True 82 | 83 | 84 | You can also use the FileMaker style manager methods to query FileMaker, these 85 | return the result as a :py:class:`filemaker.parser.FMXMLObject`: 86 | 87 | :: 88 | 89 | >>> FileMakerFlatPage.objects.find(zpkFlatpageID=1) 90 | 91 | 92 | 93 | Contents 94 | ======== 95 | 96 | .. toctree:: 97 | :maxdepth: 2 98 | 99 | managers 100 | models 101 | fields 102 | exceptions 103 | 104 | Indices and tables 105 | ================== 106 | 107 | * :ref:`genindex` 108 | * :ref:`modindex` 109 | * :ref:`search` 110 | 111 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: http://unmaintained.tech/badge.svg 2 | :target: http://unmaintained.tech/ 3 | :alt: No Maintenance Intended 4 | 5 | Unmaintained 6 | ============ 7 | 8 | We no longer intend to maintain this. 9 | 10 | We found the easiest way to interact with FileMaker was to pass XML from 11 | FileMaker through an XSLT. The XSL would generate XML that we could then pass 12 | into a parser from 13 | `djangorestframework-xml `_, 14 | and then into a `Django REST Framework `_ 15 | serializer. 16 | 17 | An example of a generic XSL stylesheet to start from can be found as 18 | filemaker.xsl in this directory. 19 | 20 | django-filemaker 21 | ================ 22 | 23 | Pythonic FileMaker® access and FileMaker layout to Django model mapping. 24 | 25 | .. image:: https://badge.fury.io/py/django-filemaker.png 26 | :target: http://badge.fury.io/py/django-filemaker 27 | 28 | .. image:: https://travis-ci.org/TitanEntertainmentGroup/django-filemaker.png?branch=master 29 | :target: https://travis-ci.org/TitanEntertainmentGroup/django-filemaker 30 | 31 | .. image:: https://coveralls.io/repos/TitanEntertainmentGroup/django-filemaker/badge.png?branch=master 32 | :target: https://coveralls.io/r/TitanEntertainmentGroup/django-filemaker?branch=master 33 | 34 | .. image:: https://pypip.in/d/django-filemaker/badge.png 35 | :target: https://crate.io/packages/django-filemaker?version=latest 36 | 37 | Quickstart 38 | ---------- 39 | 40 | Create a ``FileMakerModel``:: 41 | 42 | 43 | from django.contrib.flatpages.models import FlatPage 44 | from django.contrib.sites.models import Site 45 | from filemaker import fields, FileMakerModel 46 | 47 | 48 | class FileMakerFlatPage(FileMakerModel): 49 | 50 | # The first argument to a FileMaker field should be the field name for 51 | # that item on the FileMaker layout 52 | pk = fields.IntegerField('zpkFlatpageID') 53 | url = fields.CharField('Url_FileMaker_Field') 54 | title = fields.CharField('Title_FileMaker_Field') 55 | content = fields.CharField('Content_FileMaker_Field') 56 | # You can pass in a default value to any field 57 | template_name = fields.CharField( 58 | 'Template_Name_Field', default='flatpages/default.html') 59 | registration_required = fields.BooleanField( 60 | 'Registration_Required_Field', default=False) 61 | sites = fields.ModelListField('SITES', model=FileMakerSite) 62 | 63 | meta = { 64 | 'connection': { 65 | 'url': 'http://user:password@example.com/', 66 | 'db': 'Db_Name', 67 | 'layout': 'Layout_Name', 68 | }, 69 | 'model': FlatPage, 70 | 'pk_name': 'pk', 71 | } 72 | 73 | class FileMakerSite(FileMakerModel): 74 | # On related fields we specify the relative field to the field name 75 | # specified on the calling model (FileMakerFlatPage), unless the 76 | # calling model uses the special '+self' value which passes the layout 77 | # of that model to the sub model 78 | domain = fields.CharField('Domain_field') 79 | name = fields.CharField('Name_Field') 80 | 81 | meta = { 82 | 'model': Site, 83 | # abstract here means it is a child of an actual FileMaker layout 84 | 'abstract': True, 85 | } 86 | 87 | 88 | Query FileMaker for instances of your model, and convert them to django 89 | instances using the ``to_django`` method:: 90 | 91 | >>> # The Django style methods will convert field names 92 | >>> FlatPage.objects.count() == 0 93 | True 94 | >>> fm_page = FileMakerFlatPage.objects.get(pk=1) 95 | >>> fm_page.to_django() 96 | 97 | >>> FlatPage.objects.count() == 1 98 | True 99 | 100 | 101 | You can also use the FileMaker style manager methods to query:: 102 | 103 | >>> FileMakerFlatPage.objects.find(zpkFlatpageID=1) 104 | 105 | 106 | Documentation 107 | ------------- 108 | 109 | Full documentation is available on `ReadTheDocs 110 | `_ 111 | -------------------------------------------------------------------------------- /docs/fields.rst: -------------------------------------------------------------------------------- 1 | Fields 2 | ====== 3 | 4 | Fields provide the data coersion and validation when pulling data from 5 | FileMaker. All fields should inherit from the :py:class:`BaseFileMakerField`. 6 | 7 | .. py:class:: filemaker.fields.BaseFileMakerField(fm_attr=None, *args, **kwargs) 8 | 9 | This is the base implementation of a field. It should not be used directly, 10 | but should be inherited by every FileMaker field class. 11 | 12 | :param fm_attr: The attribute on the FileMaker layout that this field 13 | relates to. If none is given then the field name used on the 14 | FileMakerModel will be substituted. 15 | 16 | :param \**kwargs: And keyword arguments are attached as attributes to the 17 | field instance, or used to override base attributes. 18 | 19 | Aside from the ``fm_attr`` following attributes are defined by the base 20 | field: 21 | 22 | .. py:attribute:: null 23 | 24 | Determines whether this field is allowed to take a ``None`` value. 25 | 26 | .. py:attribute:: null_values 27 | 28 | The values that will be treated as ``null``, by default the empty 29 | string ``''`` and ``None``. 30 | 31 | .. py:attribute:: default 32 | 33 | The default value to use for this field if none is given. 34 | 35 | .. py:attribute:: validators 36 | 37 | A list of functions that take a single argument that will be used to 38 | validate the field. Compatible with Django's field validators. 39 | 40 | .. py:attribute:: min 41 | 42 | Specifies the minimum value this field can take. 43 | 44 | .. py:attribute:: max 45 | 46 | Specifies the maximum value this field can take. 47 | 48 | .. py:method:: coerce(self, value) 49 | 50 | Takes a value and either returns the value coerced into the required 51 | type for the field or raises a 52 | :py:exc:`filemaker.exceptions.FileMakerValidationError` with an 53 | explanatory message. This method is called internally by the private 54 | ``_coerce`` method during validation. 55 | 56 | .. py:method:: to_django(self, \*args, \**kwargs) 57 | 58 | Does any processing on the fields' value required before it can be 59 | passed to a Django model. By default this just returns ``self.value``. 60 | 61 | The current value of a FileMaker field is available using the ``value`` 62 | attribute. 63 | 64 | 65 | .. _field-reference: 66 | 67 | FileMakerField reference 68 | ------------------------ 69 | 70 | The following fields are provided by Django filemaker. 71 | 72 | .. automodule:: filemaker.fields 73 | :members: 74 | 75 | 76 | Creating custom fields 77 | ---------------------- 78 | 79 | .. py:currentmodule:: filemaker.fields 80 | 81 | If your custom field only requires a simple validation check, then it is 82 | easiest to override the validators list for a field by passing in a new list of 83 | validators. 84 | 85 | If you require more control over your field, you can subclass 86 | :py:class:`BaseFileMakerField`, or the field class that most closely resembles 87 | your desired type. The two methods that you will likely wish to overwrite are 88 | the :py:meth:`BaseFileMakerField.coerce` method, and the 89 | :py:meth:`BaseFileMakerField.to_django` method. 90 | 91 | :py:meth:`BaseFileMakerField.coerce` is called by the private 92 | ``_coerce`` method during validation. It should take a single ``value`` 93 | parameter and either return an instance of the required type, or raise a 94 | :py:exc:`filemake.exceptions.FileMakerValidationError` with an explanation of 95 | why the value could not be coerced. 96 | 97 | :py:meth:`BaseFileMakerField.to_django` does any post processing on the 98 | field required to render it suitable for passing into a Django field. By 99 | default this method just returns the field instances' current value. 100 | 101 | As an example, if we wanted to have a field that took a string and added "from 102 | FileMaker" to the end of it's value we could do: 103 | :: 104 | 105 | from filemaker.fields import CharField 106 | 107 | class FromFileMakerCharField(CharField): 108 | 109 | def coerce(self, value): 110 | text = super(FromFileMakerCharField, self).coerce(value) 111 | return '{0} from FileMaker'.format(text) 112 | 113 | 114 | If we wanted to remove the extra string before passing the value into a Django 115 | model, we could add a :py:meth:`BaseFileMakerField.to_django` method, 116 | like so: 117 | :: 118 | 119 | import re 120 | 121 | from filemaker.fields import CharField 122 | 123 | class FromFileMakerCharField(CharField): 124 | 125 | def coerce(self, value): 126 | text = super(FromFileMakerCharField, self).coerce(value) 127 | return '{0} from FileMaker'.format(text) 128 | 129 | def to_django(self): 130 | return re.sub(r' from FileMaker$', '', self.value) 131 | -------------------------------------------------------------------------------- /test_project/settings.py: -------------------------------------------------------------------------------- 1 | # Django settings for test_project project. 2 | 3 | import os 4 | 5 | 6 | DIRNAME = os.path.dirname(__file__) 7 | 8 | DEBUG = True 9 | TEMPLATE_DEBUG = DEBUG 10 | 11 | ADMINS = ( 12 | # ('Your Name', 'your_email@example.com'), 13 | ) 14 | 15 | MANAGERS = ADMINS 16 | 17 | DATABASES = { 18 | 'default': { 19 | 'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'mysql', 'sqlite3' or 'oracle'. 20 | 'NAME': ':memory:', # Or path to database file if using sqlite3. 21 | # The following settings are not used with sqlite3: 22 | 'USER': '', 23 | 'PASSWORD': '', 24 | 'HOST': '', # Empty for localhost through domain sockets or '127.0.0.1' for localhost through TCP. 25 | 'PORT': '', # Set to empty string for default. 26 | } 27 | } 28 | 29 | # Hosts/domain names that are valid for this site; required if DEBUG is False 30 | # See https://docs.djangoproject.com/en/1.5/ref/settings/#allowed-hosts 31 | ALLOWED_HOSTS = [] 32 | 33 | # Local time zone for this installation. Choices can be found here: 34 | # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name 35 | # although not all choices may be available on all operating systems. 36 | # In a Windows environment this must be set to your system time zone. 37 | TIME_ZONE = 'Europe/London' 38 | 39 | # Language code for this installation. All choices can be found here: 40 | # http://www.i18nguy.com/unicode/language-identifiers.html 41 | LANGUAGE_CODE = 'en-gb' 42 | 43 | SITE_ID = 1 44 | 45 | # If you set this to False, Django will make some optimizations so as not 46 | # to load the internationalization machinery. 47 | USE_I18N = False 48 | 49 | # If you set this to False, Django will not format dates, numbers and 50 | # calendars according to the current locale. 51 | USE_L10N = True 52 | 53 | # If you set this to False, Django will not use timezone-aware datetimes. 54 | USE_TZ = True 55 | 56 | # Absolute filesystem path to the directory that will hold user-uploaded files. 57 | # Example: "/var/www/example.com/media/" 58 | MEDIA_ROOT = os.path.join(DIRNAME, 'media/') 59 | 60 | # URL that handles the media served from MEDIA_ROOT. Make sure to use a 61 | # trailing slash. 62 | # Examples: "http://example.com/media/", "http://media.example.com/" 63 | MEDIA_URL = '/media/' 64 | 65 | # Absolute path to the directory static files should be collected to. 66 | # Don't put anything in this directory yourself; store your static files 67 | # in apps' "static/" subdirectories and in STATICFILES_DIRS. 68 | # Example: "/var/www/example.com/static/" 69 | STATIC_ROOT = os.path.join(DIRNAME, 'static/') 70 | 71 | # URL prefix for static files. 72 | # Example: "http://example.com/static/", "http://static.example.com/" 73 | STATIC_URL = '/static/' 74 | 75 | # Additional locations of static files 76 | STATICFILES_DIRS = ( 77 | # Put strings here, like "/home/html/static" or "C:/www/django/static". 78 | # Always use forward slashes, even on Windows. 79 | # Don't forget to use absolute paths, not relative paths. 80 | ) 81 | 82 | # List of finder classes that know how to find static files in 83 | # various locations. 84 | STATICFILES_FINDERS = ( 85 | 'django.contrib.staticfiles.finders.FileSystemFinder', 86 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder', 87 | # 'django.contrib.staticfiles.finders.DefaultStorageFinder', 88 | ) 89 | 90 | # Make this unique, and don't share it with anybody. 91 | SECRET_KEY = ')o3o4cx2t*ppdacgd571pi$97y8*jlihy8)qoto-$t5_-6bw9j' 92 | 93 | # List of callables that know how to import templates from various sources. 94 | TEMPLATE_LOADERS = ( 95 | 'django.template.loaders.filesystem.Loader', 96 | 'django.template.loaders.app_directories.Loader', 97 | # 'django.template.loaders.eggs.Loader', 98 | ) 99 | 100 | MIDDLEWARE_CLASSES = ( 101 | 'django.middleware.common.CommonMiddleware', 102 | 'django.contrib.sessions.middleware.SessionMiddleware', 103 | 'django.middleware.csrf.CsrfViewMiddleware', 104 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 105 | 'django.contrib.messages.middleware.MessageMiddleware', 106 | # Uncomment the next line for simple clickjacking protection: 107 | # 'django.middleware.clickjacking.XFrameOptionsMiddleware', 108 | ) 109 | 110 | ROOT_URLCONF = 'test_project.urls' 111 | 112 | # Python dotted path to the WSGI application used by Django's runserver. 113 | WSGI_APPLICATION = 'test_project.wsgi.application' 114 | 115 | TEMPLATE_DIRS = ( 116 | # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates". 117 | # Always use forward slashes, even on Windows. 118 | # Don't forget to use absolute paths, not relative paths. 119 | ) 120 | 121 | INSTALLED_APPS = ( 122 | 'django.contrib.auth', 123 | 'django.contrib.contenttypes', 124 | 'django.contrib.sessions', 125 | 'django.contrib.sites', 126 | 'django.contrib.messages', 127 | 'django.contrib.staticfiles', 128 | 'django.contrib.redirects', 129 | 'django.contrib.flatpages', 130 | # Uncomment the next line to enable the admin: 131 | # 'django.contrib.admin', 132 | # Uncomment the next line to enable admin documentation: 133 | # 'django.contrib.admindocs', 134 | 'filemaker', 135 | ) 136 | 137 | # A sample logging configuration. The only tangible logging 138 | # performed by this configuration is to send an email to 139 | # the site admins on every HTTP 500 error when DEBUG=False. 140 | # See http://docs.djangoproject.com/en/dev/topics/logging for 141 | # more details on how to customize your logging configuration. 142 | LOGGING = { 143 | 'version': 1, 144 | 'disable_existing_loggers': False, 145 | 'filters': { 146 | 'require_debug_false': { 147 | '()': 'django.utils.log.RequireDebugFalse' 148 | } 149 | }, 150 | 'handlers': { 151 | 'mail_admins': { 152 | 'level': 'ERROR', 153 | 'filters': ['require_debug_false'], 154 | 'class': 'django.utils.log.AdminEmailHandler' 155 | } 156 | }, 157 | 'loggers': { 158 | 'django.request': { 159 | 'handlers': ['mail_admins'], 160 | 'level': 'ERROR', 161 | 'propagate': True, 162 | }, 163 | } 164 | } 165 | 166 | STATIC_PREPROCESSOR_ROOT = os.path.join(DIRNAME, 'processedstatic/') 167 | -------------------------------------------------------------------------------- /docs/models.rst: -------------------------------------------------------------------------------- 1 | FileMakerModels 2 | =============== 3 | 4 | .. py:module:: filemaker.base 5 | .. py:class:: FileMakerModel 6 | 7 | :py:class:`FileMakerModel` objects provide a way to map FileMaker layouts to 8 | Django models, providing validation, and query methods. 9 | 10 | They provide a simple field based API similar to the Django model interface. 11 | 12 | .. py:method:: to_dict 13 | 14 | The :py:meth:`to_dict` method serializes the FileMaker model hierarchy 15 | represented by this model instance to a dictionary structure. 16 | 17 | .. py:method:: to_django 18 | 19 | The :py:meth:`to_django` converts this FileMaker model instance into 20 | an instance of the Django model specified by the ``model`` value of the 21 | classes :py:attr:`meta` dictionary. 22 | 23 | .. py:attribute:: meta 24 | 25 | the :py:attr:`meta` dictionary on a FileMaker model class is similar to 26 | the ``Meta`` class on a Django model. See :ref:`the-meta-dictionary`, 27 | below, for a full list of options. 28 | 29 | 30 | .. _the-meta-dictionary: 31 | 32 | The ``meta`` dictionary 33 | ----------------------- 34 | The ``meta`` dictionary on a model is equivalent to the ``Meta`` class on a 35 | Django model. The ``meta`` dictionary may contain any of the following keys. 36 | 37 | ``connection``: 38 | For the base model to be queried from a FileMaker layout, this 39 | should contain a ``connection`` dictionary with ``url``, ``db``, and 40 | ``layout`` fields (and an optional ``response_layout`` field). 41 | 42 | ``model``: 43 | The Django model class that this :py:class:`FileMakerModel` maps to. 44 | 45 | ``pk_name``: 46 | The field name on the :py:class:`FileMakerModel` that maps to the 47 | Django model ``pk`` field. By default this is ``id`` or ``pk`` 48 | whichever field is present. 49 | 50 | ``django_pk_name``: 51 | The django ``pk`` field name. This should almost never need to be 52 | changed unless you're doing (*very*) weird things with your Django models. 53 | 54 | ``django_field_map``: 55 | An optional dictionary mapping fields on the :py:class:`FileMakerModel` 56 | to fields on the Django model. By default the names are mapped 57 | one-to-one between the two. 58 | 59 | ``abstract``: 60 | If this is set to ``True`` it denotes that the model is a subsection of 61 | the layout fields, or a list-field on the layout, i.e. this model 62 | doesn't specify a ``connection`` but is connected to one that does by 63 | one or more :py:class:`filemaker.fields.ModelField` or 64 | :py:class:`filemaker.fields.ModelListField` instances. 65 | 66 | ``to_many_action``: 67 | If this is set to ``clear``, the default, then when converting 68 | :py:class:`FileMakerModel` instances to Django instances, existing 69 | many-to-many relations will be cleared before being re-added. 70 | 71 | ``ordering``: 72 | Does what it says on the tin. ``id`` by default. 73 | 74 | ``default_manager``: 75 | The default manager class to use for the model. This is 76 | :py:class:`filemaker.manager.Manager` by default. 77 | 78 | ``related`` and ``many_related``: 79 | These contain reverse entries for 80 | :py:class:`filemaker.fields.ModelField` or 81 | :py:class:`filemaker.fields.ModelListField` instances on other models 82 | pointing back to the current model. 83 | 84 | 85 | Declaring fields 86 | ---------------- 87 | 88 | Fields are declared exactly as with Django models, the exception being that 89 | FileMaker fields are used. Field names should either have the same name as 90 | their Django model counterparts, unless you are using the ``django_field_map`` 91 | attribute of the ``meta`` dictionary. For example, we could write a 92 | FileMakerModel mapping to the Django FlatPage model as the following: 93 | 94 | :: 95 | 96 | from django.contrib.flatpages.models import FlatPage 97 | from django.contrib.sites.models import Site 98 | from filemaker import FileMakerModel, fields 99 | 100 | class FileMakerSite(FileMakerModel): 101 | d = fields.CharField('FM_domain') 102 | n = fields.CharField('FM_name') 103 | 104 | meta = { 105 | 'model': Site, 106 | 'abstract': True, 107 | 'django_field_map': { 108 | 'd': 'domain', 109 | 'n': 'name', 110 | }, 111 | } 112 | 113 | class FileMakerFlatPage(FileMakerModel): 114 | 115 | url = fields.CharField('FM_url') 116 | title = fields.CharField('FM_title') 117 | content = fields.CharField('FM_content', default='') 118 | enable_comments = fields.BooleanField('FM_enable_comments') 119 | template_name = fields.CharField('FM_template_name') 120 | registration_required = fields.BooleanField('FM_registration_required') 121 | sites = fields.ModelListField('SITES', model=FileMakerSite) 122 | 123 | meta = { 124 | 'connection': { 125 | 'url': 'http://user:pass@192.168.0.2', 126 | 'db': 'main', 127 | 'layout': 'flatpages', 128 | }, 129 | 'model': FlatPage, 130 | } 131 | 132 | 133 | Here we have used different field names on the ``FileMakerSite`` model, and 134 | re-mapped them to the Django ``Site``. We have here assumed a FileMaker layout 135 | structure something like: 136 | 137 | :: 138 | 139 | - FM_url: The URL for the flatpage. 140 | FM_title: The title of the flatpage. 141 | FM_content: The content for the flatpage. 142 | FM_enable_comments: ... 143 | FM_template_name: ... 144 | FM_registration_required: ... 145 | SITES: 146 | - FM_domain: The domain of the site. 147 | FM_name: The name of the site 148 | - FM_domain: ... 149 | FM_name: ... 150 | - ... 151 | - FM_url: ... 152 | FM_title: ... 153 | ... 154 | - ... 155 | 156 | For a full list of field type see the :ref:`field-reference`. 157 | 158 | Instantiating a model instance 159 | ------------------------------ 160 | 161 | Models can be instantiated from a FileMaker result by passing a 162 | :py:class:`filemaker.parser.FMDocument` from the 163 | :py:attr:`filemaker.parser.FMXMLObject.resultset` attribute of an 164 | :py:class:`filemaker.parser.FMXMLObject` as the first argument. This is how the 165 | :py:meth:`filemaker.manager.Manager.get` and 166 | :py:meth:`filemaker.manager.Manager.filter` methods generate a list of objects 167 | internally. 168 | 169 | Alternatively you can construct an instance by passing any number of fields 170 | names as keyword arguments. So for our ``FileMakerFlatPage`` above we could do: 171 | :: 172 | >>> flat_page = FileMakerFlatPage( 173 | enable_comments=False, registration_required=False, url='/') 174 | >>> flat_page.title = 'Home' 175 | >>> flat_page.content = 'Testing, testing, 1, 2, 3.' 176 | ... 177 | 178 | Validation is performed as fields are set, e.g.: 179 | :: 180 | >>> flat_page = FileMakerFlatPage(sites='i-should-be-a-list') 181 | FileMakerValidationError: ... 182 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/django-filemaker.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/django-filemaker.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/django-filemaker" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/django-filemaker" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 31 | echo. text to make text files 32 | echo. man to make manual pages 33 | echo. texinfo to make Texinfo files 34 | echo. gettext to make PO message catalogs 35 | echo. changes to make an overview over all changed/added/deprecated items 36 | echo. xml to make Docutils-native XML files 37 | echo. pseudoxml to make pseudoxml-XML files for display purposes 38 | echo. linkcheck to check all external links for integrity 39 | echo. doctest to run all doctests embedded in the documentation if enabled 40 | goto end 41 | ) 42 | 43 | if "%1" == "clean" ( 44 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 45 | del /q /s %BUILDDIR%\* 46 | goto end 47 | ) 48 | 49 | 50 | %SPHINXBUILD% 2> nul 51 | if errorlevel 9009 ( 52 | echo. 53 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 54 | echo.installed, then set the SPHINXBUILD environment variable to point 55 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 56 | echo.may add the Sphinx directory to PATH. 57 | echo. 58 | echo.If you don't have Sphinx installed, grab it from 59 | echo.http://sphinx-doc.org/ 60 | exit /b 1 61 | ) 62 | 63 | if "%1" == "html" ( 64 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 65 | if errorlevel 1 exit /b 1 66 | echo. 67 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 68 | goto end 69 | ) 70 | 71 | if "%1" == "dirhtml" ( 72 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 73 | if errorlevel 1 exit /b 1 74 | echo. 75 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 76 | goto end 77 | ) 78 | 79 | if "%1" == "singlehtml" ( 80 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 81 | if errorlevel 1 exit /b 1 82 | echo. 83 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 84 | goto end 85 | ) 86 | 87 | if "%1" == "pickle" ( 88 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 89 | if errorlevel 1 exit /b 1 90 | echo. 91 | echo.Build finished; now you can process the pickle files. 92 | goto end 93 | ) 94 | 95 | if "%1" == "json" ( 96 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 97 | if errorlevel 1 exit /b 1 98 | echo. 99 | echo.Build finished; now you can process the JSON files. 100 | goto end 101 | ) 102 | 103 | if "%1" == "htmlhelp" ( 104 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 105 | if errorlevel 1 exit /b 1 106 | echo. 107 | echo.Build finished; now you can run HTML Help Workshop with the ^ 108 | .hhp project file in %BUILDDIR%/htmlhelp. 109 | goto end 110 | ) 111 | 112 | if "%1" == "qthelp" ( 113 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 114 | if errorlevel 1 exit /b 1 115 | echo. 116 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 117 | .qhcp project file in %BUILDDIR%/qthelp, like this: 118 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\django-filemaker.qhcp 119 | echo.To view the help file: 120 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\django-filemaker.ghc 121 | goto end 122 | ) 123 | 124 | if "%1" == "devhelp" ( 125 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 126 | if errorlevel 1 exit /b 1 127 | echo. 128 | echo.Build finished. 129 | goto end 130 | ) 131 | 132 | if "%1" == "epub" ( 133 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 134 | if errorlevel 1 exit /b 1 135 | echo. 136 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 137 | goto end 138 | ) 139 | 140 | if "%1" == "latex" ( 141 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 142 | if errorlevel 1 exit /b 1 143 | echo. 144 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 145 | goto end 146 | ) 147 | 148 | if "%1" == "latexpdf" ( 149 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 150 | cd %BUILDDIR%/latex 151 | make all-pdf 152 | cd %BUILDDIR%/.. 153 | echo. 154 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 155 | goto end 156 | ) 157 | 158 | if "%1" == "latexpdfja" ( 159 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 160 | cd %BUILDDIR%/latex 161 | make all-pdf-ja 162 | cd %BUILDDIR%/.. 163 | echo. 164 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 165 | goto end 166 | ) 167 | 168 | if "%1" == "text" ( 169 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 170 | if errorlevel 1 exit /b 1 171 | echo. 172 | echo.Build finished. The text files are in %BUILDDIR%/text. 173 | goto end 174 | ) 175 | 176 | if "%1" == "man" ( 177 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 178 | if errorlevel 1 exit /b 1 179 | echo. 180 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 181 | goto end 182 | ) 183 | 184 | if "%1" == "texinfo" ( 185 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 186 | if errorlevel 1 exit /b 1 187 | echo. 188 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 189 | goto end 190 | ) 191 | 192 | if "%1" == "gettext" ( 193 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 194 | if errorlevel 1 exit /b 1 195 | echo. 196 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 197 | goto end 198 | ) 199 | 200 | if "%1" == "changes" ( 201 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 202 | if errorlevel 1 exit /b 1 203 | echo. 204 | echo.The overview file is in %BUILDDIR%/changes. 205 | goto end 206 | ) 207 | 208 | if "%1" == "linkcheck" ( 209 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 210 | if errorlevel 1 exit /b 1 211 | echo. 212 | echo.Link check complete; look for any errors in the above output ^ 213 | or in %BUILDDIR%/linkcheck/output.txt. 214 | goto end 215 | ) 216 | 217 | if "%1" == "doctest" ( 218 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 219 | if errorlevel 1 exit /b 1 220 | echo. 221 | echo.Testing of doctests in the sources finished, look at the ^ 222 | results in %BUILDDIR%/doctest/output.txt. 223 | goto end 224 | ) 225 | 226 | if "%1" == "xml" ( 227 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 228 | if errorlevel 1 exit /b 1 229 | echo. 230 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 231 | goto end 232 | ) 233 | 234 | if "%1" == "pseudoxml" ( 235 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 236 | if errorlevel 1 exit /b 1 237 | echo. 238 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 239 | goto end 240 | ) 241 | 242 | :end 243 | -------------------------------------------------------------------------------- /filemaker/parser.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import re 5 | from collections import deque 6 | 7 | from django.utils.encoding import force_text 8 | from lxml import etree 9 | 10 | from filemaker.exceptions import FileMakerServerError 11 | 12 | 13 | class FMDocument(dict): 14 | ''' 15 | A dictionary subclass for containing a FileMaker result whose keys can 16 | be accessed as attributes. 17 | ''' 18 | 19 | def __getattr__(self, name): 20 | return self.get(name) 21 | 22 | def __setattr__(self, name, value): 23 | self[name] = value 24 | 25 | 26 | class XMLNode(object): 27 | 28 | def __init__(self, name, attrs): 29 | self.name = name.replace( 30 | '{http://www.filemaker.com/xml/fmresultset}', '') 31 | self.attrs = attrs 32 | self.text = '' 33 | self.children = [] 34 | 35 | def __getitem__(self, key): 36 | return self.attrs.get(key) 37 | 38 | def __setitem__(self, key, value): 39 | self.attrs[key] = value 40 | 41 | def __delitem__(self, key): 42 | del self.attrs[key] 43 | 44 | def add_child(self, element): 45 | self.children.append(element) 46 | 47 | def get_data(self): 48 | return self.text.strip() if hasattr(self.text, 'strip') else '' 49 | 50 | def get_elements(self, name=''): 51 | if not name: 52 | return self.children 53 | elements = [] 54 | for element in self.children: 55 | if element.name == name: 56 | elements.append(element) 57 | return elements 58 | 59 | def get_element(self, name=''): 60 | if not name: 61 | return self.children[0] 62 | for element in self.children: 63 | if element.name == name: 64 | return element 65 | 66 | 67 | class FMXMLTarget(object): 68 | 69 | def __init__(self): 70 | # It shouldn't make much difference unless you have massive 71 | # nested elements in a layout, but a deque should be faster than 72 | # a list here 73 | self.stack = deque() 74 | self.root = None 75 | 76 | def start(self, name, attrs): 77 | element = XMLNode(name, attrs) 78 | if self.stack: 79 | parent = self.stack[-1] 80 | parent.add_child(element) 81 | else: 82 | self.root = element 83 | self.stack.append(element) 84 | 85 | def end(self, name): 86 | self.stack.pop() 87 | 88 | def data(self, content): 89 | content = force_text(content) 90 | element = self.stack[-1] 91 | element.text += content 92 | 93 | def comment(self, text): # pragma: no cover 94 | pass 95 | 96 | def close(self): 97 | root = self.root 98 | self.stack = deque() 99 | self.root = None 100 | return root 101 | 102 | 103 | class FMXMLObject(object): 104 | ''' 105 | A python container container for results returned from a FileMaker request. 106 | 107 | The following attributes are provided: 108 | 109 | .. py:attribute:: data 110 | 111 | Contains the raw XML data returned from filemaker. 112 | 113 | .. py:attribute:: errorcode 114 | 115 | Contains the ``errorcode`` returned from FileMaker. Note that if this 116 | value is not zero when the data is parsed at instantiation, then a 117 | :py:exc:`filemaker.exceptions.FileMakerServerError` will be raised. 118 | 119 | .. py:attribute:: product 120 | 121 | A dictionary containing the FileMaker product details returned from 122 | the server. 123 | 124 | .. py:attribute:: database 125 | 126 | A dictionary containing the FileMaker database information returned 127 | from the server. 128 | 129 | .. py:attribute:: metadata 130 | 131 | A dictionary containing any metadata returned by the FileMaker server. 132 | 133 | .. py:attribute:: resultset 134 | 135 | A list containing any results returned from the FileMaker server as 136 | :py:class:`FMDocument` instances. 137 | 138 | .. py:attribute:: field_names 139 | 140 | A list of field names returned by the server. 141 | 142 | .. py:attribute:: target 143 | 144 | The target class used by lxml to parse the XML response from the 145 | server. By default this is an instance of :py:class:`FMXMLTarget`, but 146 | this can be overridden in subclasses. 147 | ''' 148 | 149 | target = FMXMLTarget() 150 | 151 | def __init__(self, data): 152 | self.data = data 153 | self.errorcode = -1 154 | self.product = {} 155 | self.database = {} 156 | self.metadata = {} 157 | self.resultset = [] 158 | self.field_names = [] 159 | self._parse_resultset() 160 | 161 | def __getitem__(self, key): 162 | return self.resultset[key] 163 | 164 | def __len__(self): 165 | return len(self.resultset) 166 | 167 | def _parse_xml(self): 168 | parser = etree.XMLParser(target=self.target) 169 | try: 170 | xml_obj = etree.XML(self.data, parser) 171 | if xml_obj.get_elements('ERRORCODE'): 172 | self.errorcode = \ 173 | int(xml_obj.get_elements('ERRORCODE')[0].get_data()) 174 | else: 175 | self.errorcode = int(xml_obj.get_elements('error')[0]['code']) 176 | except (KeyError, IndexError, TypeError, 177 | ValueError, etree.XMLSyntaxError): 178 | raise FileMakerServerError(954) 179 | 180 | if self.errorcode == 401: 181 | # Object not found on filemaker so return None which we pick 182 | # up later as no objects having been found 183 | return 184 | if not self.errorcode == 0: 185 | raise FileMakerServerError(self.errorcode) 186 | 187 | return xml_obj 188 | 189 | def _parse_resultset(self): 190 | data = self._parse_xml() 191 | if data is None: 192 | self.resultset = [] 193 | return 194 | self.product = data.get_element('product').attrs 195 | self.database = data.get_element('datasource').attrs 196 | definitions = data.get_element( 197 | 'metadata').get_elements('field-definition') 198 | for definition in definitions: 199 | self.metadata[definition['name']] = definition.attrs 200 | self.field_names.append(definition['name']) 201 | results = data.get_element('resultset') 202 | for result in results.get_elements('record'): 203 | record = FMDocument() 204 | for column in result.get_elements('field'): 205 | field_name = column['name'] 206 | column_data = None 207 | if column.get_element('data'): 208 | column_data = column.get_element('data').get_data() 209 | if '::' in field_name: 210 | sub_field, sub_name = field_name.split('::', 1) 211 | if not sub_field in record: 212 | record[sub_field] = FMDocument() 213 | record[sub_field][sub_name] = column_data 214 | else: 215 | record[field_name] = column_data 216 | record['RECORDID'] = int(result['record-id']) 217 | record['MODID'] = int(result['mod-id']) 218 | 219 | for sub_node in result.get_elements('relatedset'): 220 | sub_node_name = sub_node['table'] 221 | try: 222 | cnt = int(sub_node['count']) 223 | except (TypeError, ValueError): 224 | cnt = 0 225 | if cnt > 0: 226 | record[sub_node_name] = [] 227 | for sub_result in sub_node.get_elements('record'): 228 | sub_record = FMDocument() 229 | for sub_column in sub_result.get_elements('field'): 230 | field_name = re.sub( 231 | r'^{0}::'.format(sub_node_name), '', 232 | sub_column['name']) 233 | if not sub_column.get_element('data'): 234 | continue 235 | column_data = sub_column.get_element('data').get_data() 236 | if '::' in field_name: 237 | sub_field, sub_name = field_name.split('::', 1) 238 | if not sub_field in sub_record: 239 | sub_record[sub_field] = FMDocument() 240 | sub_record[sub_field][sub_name] = column_data 241 | else: 242 | sub_record[field_name] = column_data 243 | sub_record['RECORDID'] = int(sub_result['record-id']) 244 | sub_record['MODID'] = int(sub_result['mod-id']) 245 | record[sub_node_name].append(sub_record) 246 | 247 | self.resultset.append(record) 248 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # django-filemaker documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jun 28 12:48:27 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import os 15 | import re 16 | import sys 17 | from distutils import version 18 | 19 | sys.path.append( 20 | os.path.abspath( 21 | os.path.join( 22 | os.path.dirname(os.path.dirname(__file__)), 'test_project' 23 | ) 24 | ) 25 | ) 26 | os.environ['DJANGO_SETTINGS_MODULE'] = 'test_project.settings' 27 | from test_project import settings 28 | init_py = open('../filemaker/__init__.py').read() 29 | version_string = re.search( 30 | '^__version__ = [\'"]([^\'"]+)[\'"]', init_py, re.MULTILINE).group(1) 31 | # If extensions (or modules to document with autodoc) are in another directory, 32 | # add these directories to sys.path here. If the directory is relative to the 33 | # documentation root, use os.path.abspath to make it absolute, like shown here. 34 | #sys.path.insert(0, os.path.abspath('.')) 35 | 36 | # -- General configuration ----------------------------------------------------- 37 | 38 | # If your documentation needs a minimal Sphinx version, state it here. 39 | #needs_sphinx = '1.0' 40 | 41 | # Add any Sphinx extension module names here, as strings. They can be extensions 42 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 43 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.mathjax', 'sphinx.ext.viewcode'] 44 | 45 | # Add any paths that contain templates here, relative to this directory. 46 | templates_path = ['_templates'] 47 | 48 | # The suffix of source filenames. 49 | source_suffix = '.rst' 50 | 51 | # The encoding of source files. 52 | #source_encoding = 'utf-8-sig' 53 | 54 | # The master toctree document. 55 | master_doc = 'index' 56 | 57 | # General information about the project. 58 | project = u'django-filemaker' 59 | copyright = u'2013, Luke Pomfrey, Titan Entertainment Group' 60 | 61 | # The version info for the project you're documenting, acts as replacement for 62 | # |version| and |release|, also used in various other places throughout the 63 | # built documents. 64 | # 65 | # The short X.Y version. 66 | version = u'{0}'.format(version.StrictVersion(version_string)) 67 | # The full version, including alpha/beta/rc tags. 68 | release = version_string 69 | 70 | # The language for content autogenerated by Sphinx. Refer to documentation 71 | # for a list of supported languages. 72 | #language = None 73 | 74 | # There are two options for replacing |today|: either, you set today to some 75 | # non-false value, then it is used: 76 | #today = '' 77 | # Else, today_fmt is used as the format for a strftime call. 78 | #today_fmt = '%B %d, %Y' 79 | 80 | # List of patterns, relative to source directory, that match files and 81 | # directories to ignore when looking for source files. 82 | exclude_patterns = ['_build'] 83 | 84 | # The reST default role (used for this markup: `text`) to use for all documents. 85 | #default_role = None 86 | 87 | # If true, '()' will be appended to :func: etc. cross-reference text. 88 | #add_function_parentheses = True 89 | 90 | # If true, the current module name will be prepended to all description 91 | # unit titles (such as .. function::). 92 | #add_module_names = True 93 | 94 | # If true, sectionauthor and moduleauthor directives will be shown in the 95 | # output. They are ignored by default. 96 | #show_authors = False 97 | 98 | # The name of the Pygments (syntax highlighting) style to use. 99 | pygments_style = 'sphinx' 100 | 101 | # A list of ignored prefixes for module index sorting. 102 | #modindex_common_prefix = [] 103 | 104 | # If true, keep warnings as "system message" paragraphs in the built documents. 105 | #keep_warnings = False 106 | 107 | 108 | # -- Options for HTML output --------------------------------------------------- 109 | 110 | # The theme to use for HTML and HTML Help pages. See the documentation for 111 | # a list of builtin themes. 112 | html_theme = 'default' 113 | 114 | # Theme options are theme-specific and customize the look and feel of a theme 115 | # further. For a list of options available for each theme, see the 116 | # documentation. 117 | #html_theme_options = {} 118 | 119 | # Add any paths that contain custom themes here, relative to this directory. 120 | #html_theme_path = [] 121 | 122 | # The name for this set of Sphinx documents. If None, it defaults to 123 | # " v documentation". 124 | #html_title = None 125 | 126 | # A shorter title for the navigation bar. Default is the same as html_title. 127 | #html_short_title = None 128 | 129 | # The name of an image file (relative to this directory) to place at the top 130 | # of the sidebar. 131 | #html_logo = None 132 | 133 | # The name of an image file (within the static path) to use as favicon of the 134 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 135 | # pixels large. 136 | #html_favicon = None 137 | 138 | # Add any paths that contain custom static files (such as style sheets) here, 139 | # relative to this directory. They are copied after the builtin static files, 140 | # so a file named "default.css" will overwrite the builtin "default.css". 141 | html_static_path = ['_static'] 142 | 143 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 144 | # using the given strftime format. 145 | #html_last_updated_fmt = '%b %d, %Y' 146 | 147 | # If true, SmartyPants will be used to convert quotes and dashes to 148 | # typographically correct entities. 149 | #html_use_smartypants = True 150 | 151 | # Custom sidebar templates, maps document names to template names. 152 | #html_sidebars = {} 153 | 154 | # Additional templates that should be rendered to pages, maps page names to 155 | # template names. 156 | #html_additional_pages = {} 157 | 158 | # If false, no module index is generated. 159 | #html_domain_indices = True 160 | 161 | # If false, no index is generated. 162 | #html_use_index = True 163 | 164 | # If true, the index is split into individual pages for each letter. 165 | #html_split_index = False 166 | 167 | # If true, links to the reST sources are added to the pages. 168 | #html_show_sourcelink = True 169 | 170 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 171 | #html_show_sphinx = True 172 | 173 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 174 | #html_show_copyright = True 175 | 176 | # If true, an OpenSearch description file will be output, and all pages will 177 | # contain a tag referring to it. The value of this option must be the 178 | # base URL from which the finished HTML is served. 179 | #html_use_opensearch = '' 180 | 181 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 182 | #html_file_suffix = None 183 | 184 | # Output file base name for HTML help builder. 185 | htmlhelp_basename = 'django-filemakerdoc' 186 | 187 | 188 | # -- Options for LaTeX output -------------------------------------------------- 189 | 190 | latex_elements = { 191 | # The paper size ('letterpaper' or 'a4paper'). 192 | #'papersize': 'letterpaper', 193 | 194 | # The font size ('10pt', '11pt' or '12pt'). 195 | #'pointsize': '10pt', 196 | 197 | # Additional stuff for the LaTeX preamble. 198 | #'preamble': '', 199 | } 200 | 201 | # Grouping the document tree into LaTeX files. List of tuples 202 | # (source start file, target name, title, author, documentclass [howto/manual]). 203 | latex_documents = [ 204 | ('index', 'django-filemaker.tex', u'django-filemaker Documentation', 205 | u'Luke Pomfrey', 'manual'), 206 | ] 207 | 208 | # The name of an image file (relative to this directory) to place at the top of 209 | # the title page. 210 | #latex_logo = None 211 | 212 | # For "manual" documents, if this is true, then toplevel headings are parts, 213 | # not chapters. 214 | #latex_use_parts = False 215 | 216 | # If true, show page references after internal links. 217 | #latex_show_pagerefs = False 218 | 219 | # If true, show URL addresses after external links. 220 | #latex_show_urls = False 221 | 222 | # Documents to append as an appendix to all manuals. 223 | #latex_appendices = [] 224 | 225 | # If false, no module index is generated. 226 | #latex_domain_indices = True 227 | 228 | 229 | # -- Options for manual page output -------------------------------------------- 230 | 231 | # One entry per manual page. List of tuples 232 | # (source start file, name, description, authors, manual section). 233 | man_pages = [ 234 | ('index', 'django-filemaker', u'django-filemaker Documentation', 235 | [u'Luke Pomfrey'], 1) 236 | ] 237 | 238 | # If true, show URL addresses after external links. 239 | #man_show_urls = False 240 | 241 | 242 | # -- Options for Texinfo output ------------------------------------------------ 243 | 244 | # Grouping the document tree into Texinfo files. List of tuples 245 | # (source start file, target name, title, author, 246 | # dir menu entry, description, category) 247 | texinfo_documents = [ 248 | ('index', 'django-filemaker', u'django-filemaker Documentation', 249 | u'Luke Pomfrey', 'django-filemaker', 'One line description of project.', 250 | 'Miscellaneous'), 251 | ] 252 | 253 | # Documents to append as an appendix to all manuals. 254 | #texinfo_appendices = [] 255 | 256 | # If false, no module index is generated. 257 | #texinfo_domain_indices = True 258 | 259 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 260 | #texinfo_show_urls = 'footnote' 261 | 262 | # If true, do not generate a @detailmenu in the "Top" node's menu. 263 | #texinfo_no_detailmenu = False 264 | 265 | 266 | # -- Options for Epub output --------------------------------------------------- 267 | 268 | # Bibliographic Dublin Core info. 269 | epub_title = u'django-filemaker' 270 | epub_author = u'Luke Pomfrey' 271 | epub_publisher = u'Luke Pomfrey' 272 | epub_copyright = u'2013, Luke Pomfrey' 273 | 274 | # The language of the text. It defaults to the language option 275 | # or en if the language is not set. 276 | #epub_language = '' 277 | 278 | # The scheme of the identifier. Typical schemes are ISBN or URL. 279 | #epub_scheme = '' 280 | 281 | # The unique identifier of the text. This can be a ISBN number 282 | # or the project homepage. 283 | #epub_identifier = '' 284 | 285 | # A unique identification for the text. 286 | #epub_uid = '' 287 | 288 | # A tuple containing the cover image and cover page html template filenames. 289 | #epub_cover = () 290 | 291 | # A sequence of (type, uri, title) tuples for the guide element of content.opf. 292 | #epub_guide = () 293 | 294 | # HTML files that should be inserted before the pages created by sphinx. 295 | # The format is a list of tuples containing the path and title. 296 | #epub_pre_files = [] 297 | 298 | # HTML files shat should be inserted after the pages created by sphinx. 299 | # The format is a list of tuples containing the path and title. 300 | #epub_post_files = [] 301 | 302 | # A list of files that should not be packed into the epub file. 303 | #epub_exclude_files = [] 304 | 305 | # The depth of the table of contents in toc.ncx. 306 | #epub_tocdepth = 3 307 | 308 | # Allow duplicate toc entries. 309 | #epub_tocdup = True 310 | 311 | # Fix unsupported image types using the PIL. 312 | #epub_fix_images = False 313 | 314 | # Scale large images. 315 | #epub_max_image_width = 0 316 | 317 | # If 'no', URL addresses will not be shown. 318 | #epub_show_urls = 'inline' 319 | 320 | # If false, no index is generated. 321 | #epub_use_index = True 322 | -------------------------------------------------------------------------------- /filemaker/base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from copy import deepcopy 5 | 6 | from django.db.models import FieldDoesNotExist, ForeignKey, ManyToManyField 7 | from django.utils import six 8 | 9 | from filemaker.exceptions import FileMakerObjectDoesNotExist 10 | 11 | try: 12 | from functools import total_ordering 13 | except ImportError: # pragma: no cover 14 | # Python < 2.7 15 | def total_ordering(cls): # NOQA 16 | 'Class decorator that fills-in missing ordering methods' 17 | convert = { 18 | '__lt__': [('__gt__', lambda self, other: other < self), 19 | ('__le__', lambda self, other: not other < self), 20 | ('__ge__', lambda self, other: not self < other)], 21 | '__le__': [('__ge__', lambda self, other: other <= self), 22 | ('__lt__', lambda self, other: not other <= self), 23 | ('__gt__', lambda self, other: not self <= other)], 24 | '__gt__': [('__lt__', lambda self, other: other > self), 25 | ('__ge__', lambda self, other: not other > self), 26 | ('__le__', lambda self, other: not self > other)], 27 | '__ge__': [('__le__', lambda self, other: other >= self), 28 | ('__gt__', lambda self, other: not other >= self), 29 | ('__lt__', lambda self, other: not self >= other)] 30 | } 31 | if hasattr(object, '__lt__'): 32 | roots = [op for op in convert 33 | if getattr(cls, op) is not getattr(object, op)] 34 | else: 35 | roots = set(dir(cls)) & set(convert) 36 | assert roots, 'must define at least one ordering operation: < > <= >=' 37 | root = max(roots) # prefer __lt __ to __le__ to __gt__ to __ge__ 38 | for opname, opfunc in convert[root]: 39 | if opname not in roots: 40 | opfunc.__name__ = opname 41 | opfunc.__doc__ = getattr(int, opname).__doc__ 42 | setattr(cls, opname, opfunc) 43 | return cls 44 | 45 | 46 | class ManagerDescriptor(object): 47 | 48 | def __init__(self, manager): 49 | self.manager = manager 50 | 51 | def __get__(self, instance, type=None): 52 | if instance is not None: 53 | raise AttributeError( 54 | 'Manager isn\'t accessible via {0} instances'.format( 55 | type.__name__)) 56 | return self.manager(type) 57 | 58 | 59 | class BaseFileMakerModel(type): 60 | 61 | def __new__(cls, name, bases, attrs): 62 | from filemaker.fields import BaseFileMakerField 63 | from filemaker.manager import Manager 64 | super_new = super(BaseFileMakerModel, cls).__new__ 65 | 66 | if name == 'NewBase' and attrs == {}: 67 | return super_new(cls, name, bases, attrs) 68 | 69 | meta_override = attrs.pop('meta', {}) 70 | fields = [] 71 | new_attrs = [] 72 | new_attrs.append(( 73 | 'DoesNotExist', 74 | type(str('DoesNotExist'), (FileMakerObjectDoesNotExist,), {}) 75 | )) 76 | for attr_name, attr_value in attrs.items(): 77 | if isinstance(attr_value, BaseFileMakerField): 78 | attr_value.name = attr_name 79 | if attr_value.fm_attr is None: 80 | attr_value.fm_attr = attr_name 81 | field = deepcopy(attr_value) 82 | fields.append((attr_name, field)) 83 | else: 84 | new_attrs.append((attr_name, attr_value)) 85 | fields = dict(fields) 86 | new_attrs.append(('_fields', fields)) 87 | meta = { 88 | 'connection': None, 89 | 'pk_name': 90 | 'id' if 'id' in fields else ('pk' if 'pk' in fields else None), 91 | 'django_pk_name': 'pk', 92 | 'django_model': None, 93 | 'django_field_map': None, 94 | 'abstract': False, 95 | 'to_many_action': 'clear', 96 | 'ordering': 'id' if 'id' in fields else None, 97 | 'default_manager': Manager, 98 | 'related': [], 99 | 'many_related': [], 100 | } 101 | if isinstance(meta_override, dict): 102 | meta.update(meta_override) 103 | new_attrs.append(('_meta', meta)) 104 | new_class = super_new(cls, name, bases, dict(new_attrs)) 105 | new_class._attach_manager() 106 | new_class._process_fields() 107 | return new_class 108 | 109 | def _attach_manager(cls): 110 | 111 | from filemaker.manager import Manager 112 | 113 | if not cls._meta['abstract'] and cls._meta['connection']: 114 | setattr(cls, '_base_manager', ManagerDescriptor(Manager)) 115 | setattr( 116 | cls, 117 | '_default_manager', 118 | ManagerDescriptor(cls._meta['default_manager']) 119 | ) 120 | if not isinstance(getattr( 121 | cls, 'objects', None), cls._meta['default_manager']): 122 | setattr( 123 | cls, 124 | 'objects', 125 | ManagerDescriptor(cls._meta['default_manager']) 126 | ) 127 | 128 | def _process_fields(cls): 129 | for field in cls._fields.values(): 130 | if hasattr(field, 'contribute_to_class'): 131 | field.contribute_to_class(cls) 132 | 133 | 134 | @total_ordering 135 | class FileMakerModel(six.with_metaclass(BaseFileMakerModel)): 136 | 137 | def __init__(self, fm_obj=None, **kwargs): 138 | self._fields = deepcopy(self._fields) 139 | self._meta = deepcopy(self._meta) 140 | 141 | def make_prop(field_name): 142 | 143 | def getter(self): 144 | return self._fields[field_name].value 145 | 146 | def setter(self, value): 147 | self._fields[field_name].value = value 148 | 149 | return property(getter, setter) 150 | 151 | for field_name, field in self._fields.items(): 152 | field._value = field.default 153 | setattr(self.__class__, field_name, make_prop(field_name)) 154 | if fm_obj is not None: 155 | for field_name, field in self._fields.items(): 156 | value = deep_getattr(fm_obj, field.fm_attr) 157 | field.value = value 158 | else: 159 | for name, value in kwargs.items(): 160 | setattr(self, name, value) 161 | self._fm_obj = fm_obj 162 | super(FileMakerModel, self).__init__() 163 | 164 | def __eq__(self, other): 165 | if not isinstance(other, self.__class__): 166 | return False 167 | for field in self._fields: 168 | if not field in other._fields \ 169 | or not other._fields[field] == self._fields[field]: 170 | return False 171 | return True 172 | 173 | def __lt__(self, other): 174 | if not isinstance(other, self.__class__): 175 | raise TypeError('Cannot compare {0} to {1}' 176 | .format(self.__class__, other.__class__)) 177 | ordering = self._meta['ordering'] 178 | if ordering is None: 179 | raise ValueError('You must specify a field to order by in meta') 180 | if ordering.startswith('-'): 181 | ordering = ordering[1:] 182 | return getattr(self, ordering) > getattr(other, ordering) 183 | return getattr(self, ordering) < getattr(other, ordering) 184 | 185 | def get_django_instance(self): 186 | if self._meta['pk_name']: 187 | pk = getattr(self, self._meta['pk_name'], None) 188 | else: 189 | pk = None 190 | manager = self._meta['model']._default_manager 191 | lookup = {self._meta['django_pk_name']: pk} 192 | if pk is not None and manager.filter(**lookup).exists(): 193 | obj = manager.get(**lookup) 194 | elif pk is not None: 195 | obj = self._meta['model'](**lookup) 196 | else: 197 | obj = self._meta['model']() 198 | return obj 199 | 200 | def to_django(self, *args, **kwargs): 201 | from filemaker.fields import ModelField, ModelListField 202 | if self._meta.get('model', None) is None: 203 | return 204 | obj = self.get_django_instance() 205 | to_one_rels = [] 206 | to_many_rels = [] 207 | if self._meta['django_field_map']: 208 | for field, dj_field in self._meta['django_field_map']: 209 | if isinstance(self._fields[field], ModelListField): 210 | to_many_rels.append((dj_field, self._fields[field])) 211 | elif isinstance(self._fields[field], ModelField): 212 | to_one_rels.append((dj_field, self._fields[field])) 213 | else: 214 | setattr(obj, dj_field, 215 | self._fields[field].to_django()) 216 | else: 217 | for field, instance in self._fields.items(): 218 | if isinstance(instance, ModelListField): 219 | to_many_rels.append((field, instance)) 220 | elif isinstance(instance, ModelField): 221 | to_one_rels.append((field, instance)) 222 | else: 223 | setattr(obj, field, instance.to_django()) 224 | for field_name, field in to_one_rels: 225 | instance = field.to_django() 226 | setattr(obj, field_name, instance) 227 | if kwargs.get('save', True): 228 | obj.save() 229 | for field_name, field in to_many_rels: 230 | instances = field.to_django(save=False) 231 | try: 232 | obj._meta.get_field(field_name) 233 | except FieldDoesNotExist: 234 | # If we're here then this is a reverse relationship 235 | rel_field = None 236 | for model_field in field.model._meta['model']._meta.fields: 237 | if isinstance(model_field, (ForeignKey, ManyToManyField)) \ 238 | and model_field.rel.to == obj.__class__: 239 | rel_field = model_field.name 240 | break 241 | if rel_field is not None: 242 | if self._meta['to_many_action'] == 'clear': 243 | field.model._meta['model']._default_manager.filter( 244 | **{rel_field: obj}).delete() 245 | [setattr(instance, rel_field, obj) 246 | for instance in instances] 247 | [instance.save() for instance in instances] 248 | else: 249 | # This looks like a m2m on the obj 250 | manager = getattr(obj, field_name) 251 | if self._meta['to_many_action'] == 'clear': 252 | manager.clear() 253 | [instance.save() for instance in instances] 254 | manager.add(*instances) 255 | return obj 256 | 257 | def to_dict(self, *args, **kwargs): 258 | from filemaker.fields import ModelField, ModelListField 259 | field_dict = {} 260 | for field, instance in self._fields.items(): 261 | if isinstance(instance, ModelListField): 262 | field_dict[field] = \ 263 | [i.to_dict() for i in instance.value] 264 | elif isinstance(instance, ModelField): 265 | field_dict[field] = instance.value.to_dict() 266 | else: 267 | field_dict[field] = instance.value 268 | return field_dict 269 | 270 | 271 | def deep_getattr(obj, attr): 272 | value = obj 273 | if not hasattr(attr, 'strip') or not attr.strip(): 274 | raise ValueError('You must specify an attribute name') 275 | if attr.strip() == '+self': 276 | return value 277 | for sub_attr in attr.split('.'): 278 | try: 279 | value = getattr(value, sub_attr) 280 | except AttributeError: 281 | return None 282 | return value 283 | -------------------------------------------------------------------------------- /filemaker/exceptions.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | from django.core.exceptions import ValidationError 5 | 6 | 7 | __all__ = ['FileMakerError', 'FileMakerValidationError', 8 | 'FileMakerObjectDoesNotExist', 'FileMakerConnectionError', 9 | 'FileMakerServerError'] 10 | 11 | 12 | class FileMakerError(Exception): 13 | ''' 14 | This is the base exception related to FileMaker operations. All other 15 | exceptions here inherit from it. 16 | ''' 17 | pass 18 | 19 | 20 | class FileMakerValidationError(FileMakerError, ValidationError): 21 | ''' 22 | Raised when a FileMaker field fails validation. The same as Django's 23 | ``django.core.exceptions.ValidationError`` (from which it inherits). 24 | 25 | When raised by a field, the field raising it will be available on the 26 | exception as the ``field`` attribute. 27 | ''' 28 | 29 | def __init__(self, *args, **kwargs): 30 | self.field = kwargs.pop('field', None) 31 | return super(FileMakerValidationError, self).__init__(*args, **kwargs) 32 | 33 | 34 | class FileMakerObjectDoesNotExist(FileMakerError): 35 | ''' 36 | Raised when no :py:class:`FileMakerModel` instance matching a query is 37 | found. 38 | 39 | Every :py:class:`FileMakerModel` has a sub-class of this as `DoesNotExist` 40 | enabling you to catch exceptions raised by a specific model. 41 | ''' 42 | pass 43 | 44 | 45 | class FileMakerConnectionError(FileMakerError): 46 | ''' 47 | Raised when an HTTP or other error is encountered when attempting to 48 | connect to the FileMaker server. 49 | ''' 50 | pass 51 | 52 | 53 | class FileMakerServerError(FileMakerError): 54 | ''' 55 | Indicates an error returned by FileMaker. This is raised by the result 56 | parser and in turn by the FileMaker managers. 57 | 58 | The exact FileMaker error code is available on the exception as the 59 | ``code`` attribute, and the corresponding text representation of the error 60 | is on the ``message`` attribute. 61 | ''' 62 | 63 | error_codes = { 64 | -1: 'Unknown error', 65 | 0: 'Non FileMaker error', 66 | 1: 'User canceled action', 67 | 2: 'Memory error', 68 | 3: 'Command is unavailable (for example, wrong operating system, ' 69 | 'wrong mode, etc.)', 70 | 4: 'Command is unknown', 71 | 5: 'Command is invalid (for example, a Set Field script step does ' 72 | 'not have a calculation specified)', 73 | 6: 'File is read-only', 74 | 7: 'Running out of memory', 75 | 8: 'Empty result', 76 | 9: 'Insufficient privileges', 77 | 10: 'Requested data is missing', 78 | 11: 'Name is not valid', 79 | 12: 'Name already exists', 80 | 13: 'File or object is in use', 81 | 14: 'Out of range', 82 | 15: 'Can\'t divide by zero', 83 | 16: 'Operation failed, request retry (for example, a user query)', 84 | 17: 'Attempt to convert foreign character set to UTF-16 failed', 85 | 18: 'Client must provide account information to proceed', 86 | 19: 'String contains characters other than A-Z, a-z, 0-9 (ASCII)', 87 | 100: 'File is missing', 88 | 101: 'Record is missing', 89 | 102: 'Field is missing', 90 | 103: 'Relationship is missing', 91 | 104: 'Script is missing', 92 | 105: 'Layout is missing', 93 | 106: 'Table is missing', 94 | 107: 'Index is missing', 95 | 108: 'Value list is missing', 96 | 109: 'Privilege set is missing', 97 | 110: 'Related tables are missing', 98 | 111: 'Field repetition is invalid', 99 | 112: 'Window is missing', 100 | 113: 'Function is missing', 101 | 114: 'File reference is missing', 102 | 130: 'Files are damaged or missing and must be reinstalled', 103 | 131: 'Language pack files are missing (such as template files)', 104 | 200: 'Record access is denied', 105 | 201: 'Field cannot be modified', 106 | 202: 'Field access is denied', 107 | 203: 'No records in file to print, or password doesn\'t allow print ' 108 | 'access', 109 | 204: 'No access to field(s) in sort order', 110 | 205: 'User does not have access privileges to create new records; ' 111 | 'import will overwrite existing data', 112 | 206: 'User does not have password change privileges, or file is ' 113 | 'not modifiable', 114 | 207: 'User does not have sufficient privileges to change database ' 115 | 'schema, or file is not modifiable', 116 | 208: 'Password does not contain enough characters', 117 | 209: 'New password must be different from existing one', 118 | 210: 'User account is inactive', 119 | 211: 'Password has expired', 120 | 212: 'Invalid user account and/or password. Please try again', 121 | 213: 'User account and/or password does not exist', 122 | 214: 'Too many login attempts', 123 | 215: 'Administrator privileges cannot be duplicated', 124 | 216: 'Guest account cannot be duplicated', 125 | 217: 'User does not have sufficient privileges to modify ' 126 | 'administrator account', 127 | 300: 'File is locked or in use', 128 | 301: 'Record is in use by another user', 129 | 302: 'Table is in use by another user', 130 | 303: 'Database schema is in use by another user', 131 | 304: 'Layout is inMa use by another user', 132 | 306: 'Record modification ID does not match', 133 | 400: 'Find criteria are empty', 134 | 401: 'No records matMach the request', 135 | 402: 'Selected field is not a match field for a lookup', 136 | 403: 'Exceeding maximum record limit for trial version of ' 137 | 'FileMaker(tm)) Pro', 138 | 404: 'Sort order is invalid', 139 | 405: 'Number of records specified exceeds number of records that ' 140 | 'can be omitted', 141 | 406: 'Replace/Reserialize criteria are invalid', 142 | 407: 'One or both match fields are missing (invalid relationship)', 143 | 408: 'Specified field has inappropriate data type for this operation', 144 | 409: 'Import order is invalid', 145 | 410: 'Export order is invalid', 146 | 412: 'Wrong version of FileMaker(tm) Pro used to recover file', 147 | 413: 'Specified field has inappropriate field type', 148 | 414: 'Layout cannot display the result', 149 | 415: 'Related Record Required', 150 | 500: 'Date value does not meet validation entry options', 151 | 501: 'Time value does not meet validation entry options', 152 | 502: 'Number value does not meet validation entry options', 153 | 503: 'Value in field is not within the range specified in ' 154 | 'validation entry options', 155 | 504: 'Value in field is not unique as required in validation ' 156 | 'entry options', 157 | 505: 'Value in field is not an existing value in the database ' 158 | 'file as required in validation entry options', 159 | 506: 'Value in field is not listed on the value list specified ' 160 | 'in validation entry option', 161 | 507: 'Value in field failed calculation test of validation entry ' 162 | 'option', 163 | 508: 'Invalid value entered in Find mode', 164 | 509: 'Field requires a valid value', 165 | 510: 'Related value is empty or unavailable', 166 | 511: 'Value in field exceeds maximum number of allowed characters', 167 | 600: 'Print error has occurred', 168 | 601: 'Combined header and footer exceed one page', 169 | 602: 'Body doesn\'t fit on a page for current column setup', 170 | 603: 'Print connection lost', 171 | 700: 'File is of the wrong file type for import', 172 | 706: 'EPSF file has no preview image', 173 | 707: 'Graphic translator cannot be found', 174 | 708: 'Can\'t import the file or need color monitor support to ' 175 | 'import file', 176 | 709: 'QuickTime movie import failed', 177 | 710: 'Unable to update QuickTime file reference because the ' 178 | 'database file is read-only', 179 | 711: 'Import translator cannot be found', 180 | 714: 'Password privileges do not allow the operation', 181 | 715: 'Specified Excel worksheet or named range is missing', 182 | 716: 'A SQL query using DELETE, INSERT, or UPDATE is not allowed ' 183 | 'for ODBC import', 184 | 717: 'There is not enough XML/XSL information to proceed with the ' 185 | 'import or export', 186 | 718: 'Error in parsing XML file (from Xerces)', 187 | 719: 'Error in transforming XML using XSL (from Xalan)', 188 | 720: 'Error when exporting; intended format does not support ' 189 | 'repeating fields', 190 | 721: 'Unknown error occurred in the parser or the transformer', 191 | 722: 'Cannot import data into a file that has no fields', 192 | 723: 'You do not have permission to add records to or modify ' 193 | 'records in the target table', 194 | 724: 'You do not have permission to add records to the target table', 195 | 725: 'You do not have permission to modify records in the ' 196 | 'target table', 197 | 726: 'There are more records in the import file than in the ' 198 | 'target table. Not all records were imported', 199 | 727: 'There are more records in the target table than in the ' 200 | 'import file. Not all records were updated', 201 | 729: 'Errors occurred during import. Records could not be imported', 202 | 730: 'Unsupported Excel version. (Convert file to Excel 7.0 ' 203 | '(Excel 95), Excel 97, 2000, or XP format and try again)', 204 | 731: 'The file you are importing from contains no data', 205 | 732: 'This file cannot be inserted because it contains other files', 206 | 733: 'A table cannot be imported into itself', 207 | 734: 'This file type cannot be displayed as a picture', 208 | 735: 'This file type cannot be displayed as a picture. It will be ' 209 | 'inserted and displayed as a file 800 Unable to create file ' 210 | 'on disk', 211 | 801: 'Unable to create temporary file on System disk', 212 | 802: 'Unable to open file', 213 | 803: 'File is single user or host cannot be found', 214 | 804: 'File cannot be opened as read-only in its current state', 215 | 805: 'File is damaged; use Recover command', 216 | 806: 'File cannot be opened with this version of FileMaker(tm) Pro', 217 | 807: 'File is not a FileMaker(tm) Pro file or is severely damaged', 218 | 808: 'Cannot open file because access privileges are damaged', 219 | 809: 'Disk/volume is full', 220 | 810: 'Disk/volume is locked', 221 | 811: 'Temporary file cannot be opened as FileMaker(tm) Pro file', 222 | 813: 'Record Synchronization error on network', 223 | 814: 'File(s) cannot be opened because maximum number is open', 224 | 815: 'Couldn\'t open lookup file', 225 | 816: 'Unable to convert file', 226 | 817: 'Unable to open file because it does not belong to this solution', 227 | 819: 'Cannot save a local copy of a remote file', 228 | 820: 'File is in the process of being closed', 229 | 821: 'Host forced a disconnect', 230 | 822: 'FMI files not found; reinstall missing files', 231 | 823: 'Cannot set file to single-user, guests are connected', 232 | 824: 'File is damaged or not a FileMaker(tm) file', 233 | 900: 'General spelling engine error', 234 | 901: 'Main spelling dictionary not installed', 235 | 902: 'Could not launch the Help system', 236 | 903: 'Command cannot be used in a shared file', 237 | 904: 'Command can only be used in a file hosted under ' 238 | 'FileMaker(tm) Server', 239 | 905: 'No active field selected; command can only be used if there ' 240 | 'is an active field', 241 | 920: 'Can\'t initialize the spelling engine', 242 | 921: 'User dictionary cannot be loaded for editing', 243 | 922: 'User dictionary cannot be found', 244 | 923: 'User dictionary is read-only', 245 | 951: 'An unexpected error occurred (returned only by ' 246 | 'web-published databases)', 247 | 954: 'Unsupported XML grammar (returned only by ' 248 | 'web-published databases)', 249 | 955: 'No database name (returned only by web-published databases)', 250 | 956: 'Maximum number of database sessions exceeded (returned ' 251 | 'only by web-published databases)', 252 | 957: 'Conflicting commands (returned only by web-published databases)', 253 | 958: 'Parameter missing (returned only by web-published databases)', 254 | 971: 'The user name is invalid', 255 | 972: 'The password is invalid', 256 | 973: 'The database is invalid', 257 | 974: 'Permission Denied', 258 | 975: 'The field has restricted access', 259 | 976: 'Security is disabled', 260 | 977: 'Invalid client IP address', 261 | 978: 'The number of allowed guests has been exceeded', 262 | 1200: 'Generic calculation error', 263 | 1201: 'Too few parameters in the function', 264 | 1202: 'Too many parameters in the function', 265 | 1203: 'Unexpected end of calculation', 266 | 1204: 'Number, text constant, field name or "(" expected', 267 | 1205: 'Comment is not terminated with "*/"', 268 | 1206: 'Text constant must end with a quotation mark', 269 | 1207: 'Unbalanced parenthesis', 270 | 1208: 'Operator missing, function not found or "(" not expected', 271 | 1209: 'Name (such as field name or layout name) is missing', 272 | 1210: 'Plug-in function has already been registered', 273 | 1211: 'List usage is not allowed in this function', 274 | 1212: 'An operator (for example, +, -, *) is expected here', 275 | 1213: 'This variable has already been defined in the Let function', 276 | 1214: 'AVERAGE, COUNT, EXTEND, GETREPETITION, MAX, MIN, NPV, ' 277 | 'STDEV, SUM and GETSUMMARY: expression found where a field ' 278 | 'alone is needed', 279 | 1215: 'This parameter is an invalid Get function parameter', 280 | 1216: 'Only Summary fields allowed as first argument in GETSUMMARY', 281 | 1217: 'Break field is invalid', 282 | 1218: 'Cannot evaluate the number', 283 | 1219: 'A field cannot be used in its own formula', 284 | 1220: 'Field type must be normal or calculated', 285 | 1221: 'Data type must be number, date, time, or timestamp', 286 | 1222: 'Calculation cannot be stored', 287 | 1223: 'The function referred to does not exist', 288 | 1400: 'ODBC driver initialization failed; make sure the ODBC ' 289 | 'drivers are properly installed', 290 | 1401: 'Failed to allocate environment (ODBC)', 291 | 1402: 'Failed to free environment (ODBC)', 292 | 1403: 'Failed to disconnect (ODBC)', 293 | 1404: 'Failed to allocate connection (ODBC)', 294 | 1405: 'Failed to free connection (ODBC)', 295 | 1406: 'Failed check for SQL API (ODBC)', 296 | 1407: 'Failed to allocate statement (ODBC)', 297 | 1408: 'Extended error (ODBC)', 298 | } 299 | 300 | def __init__(self, code=-1): 301 | if not code in self.error_codes: 302 | code = -1 303 | self.message = 'FileMaker Error: {0}: {1}'.format( 304 | code, self.error_codes.get(code)) 305 | 306 | def __str__(self): 307 | return self.message 308 | -------------------------------------------------------------------------------- /filemaker/manager.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import copy 5 | import re 6 | 7 | import requests 8 | from django.http import QueryDict 9 | from django.utils import six 10 | from urlobject import URLObject 11 | 12 | from filemaker.exceptions import FileMakerConnectionError 13 | from filemaker.parser import FMXMLObject 14 | 15 | 16 | OPERATORS = { 17 | 'exact': 'eq', 18 | 'contains': 'cn', 19 | 'startswith': 'bw', 20 | 'endswith': 'ew', 21 | 'gt': 'gt', 22 | 'gte': 'gte', 23 | 'lt': 'lt', 24 | 'lte': 'lte', 25 | 'neq': 'neq', 26 | } 27 | 28 | 29 | class RawManager(object): 30 | ''' 31 | The raw manager allows you to query the FileMaker web interface. 32 | 33 | Most manager methods (the exceptions being the committing methods; 34 | ``find``, ``find_all``, ``edit``, ``new``, and ``delete``) are chainable, 35 | enabling usage like 36 | :: 37 | 38 | manager = RawManager(...) 39 | manager = manager.filter(field=value).add_sort_param('some_field') 40 | results = manager.find_all() 41 | ''' 42 | 43 | def __init__(self, url, db, layout, response_layout=None, **kwargs): 44 | ''' 45 | :param url: The URL to access the FileMaker server. This should contain 46 | any authorization credentials. If a path is not provided (e.g. no 47 | trailing slash, like ``http://username:password@192.168.1.2``) then 48 | the default path of ``/fmi/xml/fmresultset.xml`` will be used. 49 | :param db: The database name to access (sets the ``-db`` parameter). 50 | :param layout: The layout to use (sets the ``-lay`` parameter). 51 | :param response_layout: (*Optional*) The layout to use (sets the 52 | ``-lay.response`` parameter). 53 | ''' 54 | self.url = URLObject(url).without_auth() 55 | self.url = self.url.with_path( 56 | self.url.path or '/fmi/xml/fmresultset.xml') 57 | self.auth = URLObject(url).auth 58 | self.params = QueryDict('', mutable=True) 59 | self.dbparams = QueryDict('', mutable=True) 60 | self.dbparams.update({ 61 | '-db': db, 62 | '-lay': layout, 63 | }) 64 | if response_layout: 65 | self.dbparams['-lay.response'] = response_layout 66 | self.params['-max'] = '50' 67 | 68 | def __repr__(self): 69 | return ''.format( 70 | self.url, self.dbparams, self.params) 71 | 72 | def _clone(self): 73 | return copy.copy(self) 74 | 75 | def set_script(self, name, option=None): 76 | ''' 77 | Sets the name of the filemaker script to use 78 | 79 | :param name: The name of the script to use. 80 | :param option: (*Optional*) Can be one of ``presort`` or ``prefind``. 81 | ''' 82 | mgr = self._clone() 83 | key = '-script' 84 | if option in ('prefind', 'presort'): 85 | key = '{0}.{1}'.format(key, option) 86 | mgr.params[key] = name 87 | return mgr 88 | 89 | def set_record_id(self, recid): 90 | ''' 91 | Sets the ``-recid`` parameter. 92 | 93 | :param recid: The record ID to set. 94 | ''' 95 | mgr = self._clone() 96 | mgr.params['-recid'] = recid 97 | return mgr 98 | 99 | def set_modifier_id(self, modid): 100 | ''' 101 | Sets the ``-modid`` parameter. 102 | 103 | :param modid: The modifier ID to set. 104 | ''' 105 | mgr = self._clone() 106 | mgr.params['-modid'] = modid 107 | return mgr 108 | 109 | def set_logical_operator(self, op): 110 | ''' 111 | Set the logical operator to be used for this query using the ``-op`` 112 | parameter. 113 | 114 | :param op: Must be one of ``and`` or ``or``. 115 | ''' 116 | mgr = self._clone() 117 | if op in ('and', 'or'): 118 | mgr.params['-lop'] = op 119 | return mgr 120 | 121 | def set_group_size(self, max): 122 | ''' 123 | Set the group size to return from FileMaker using the ``-max``. 124 | 125 | This is defaulted to 50 when the manager is initialized. 126 | 127 | :param integer max: The number of records to return. 128 | ''' 129 | self.params['-max'] = max 130 | return self 131 | 132 | def set_skip_records(self, skip): 133 | ''' 134 | The number of records to skip when retrieving records from FileMaker 135 | using the ``-skip`` parameter. 136 | 137 | :param integer skip: The number of records to skip. 138 | ''' 139 | self.params['-skip'] = skip 140 | return self 141 | 142 | def add_db_param(self, field, value, op=None): 143 | ''' 144 | Adds an arbitrary parameter to the query to be performed. An optional 145 | operator parameter may be specified which will add an additional field 146 | to the parameters. e.g. ``.add_db_param('foo', 'bar')`` sets the 147 | parameter ``...&foo=bar&...``, ``.add_db_param('foo', 'bar', 'gt')`` 148 | sets ``...&foo=bar&foo.op=gt&...``. 149 | 150 | :param field: The field to query on. 151 | :param value: The query value. 152 | :param op: (*Optional*) The operator to use for this query. 153 | ''' 154 | mgr = self._clone() 155 | mgr.params.appendlist(field, value) 156 | if op: 157 | mgr.params.appendlist('{0}.op'.format(field), op) 158 | return mgr 159 | 160 | def add_sort_param(self, field, order='ascend', priority=0): 161 | ''' 162 | Add a sort field to the query. 163 | 164 | :param field: The field to sort on. 165 | :param order: (*Optional*, defaults to ``ascend``) The direction to 166 | sort, one of ``ascending`` or ``descending``. 167 | :param priority: (*Optional*, defaults to ``0``) the order to apply 168 | this sort in if multiple sort fields are specified. 169 | ''' 170 | mgr = self._clone() 171 | mgr.params['-sortfield.{0}'.format(priority)] = field 172 | mgr.params['-sortorder.{0}'.format(priority)] = order 173 | return mgr 174 | 175 | def find(self, **kwargs): 176 | ''' 177 | Performs the -find command. This method internally calls ``_commit`` 178 | and is not chainable. 179 | 180 | :param \**kwargs: Any additional fields to search on, which will be 181 | passed directly into the URL parameters. 182 | :rtype: :py:class:`filemaker.parser.FMXMLObject` 183 | ''' 184 | self.params.update(kwargs) 185 | return self._commit('find') 186 | 187 | def find_all(self, **kwargs): 188 | ''' 189 | Performs the -findall command to return all records. This method 190 | internally calls ``_commit`` and is not chainable. 191 | 192 | :param \**kwargs: Any additional URL parameters. 193 | :rtype: :py:class:`filemaker.parser.FMXMLObject` 194 | ''' 195 | self.params.update(kwargs) 196 | return self._commit('findall') 197 | 198 | def edit(self, **kwargs): 199 | ''' 200 | Updates a record using the ``-edit`` command. This method 201 | internally calls ``_commit`` and is not chainable. 202 | 203 | You should have either called the :py:meth:`set_record_id` and/or 204 | :py:meth:`set_modifier_id` methods on the manager, or passed in 205 | ``RECORDID`` or ``MODID`` as params. 206 | 207 | :param \**kwargs: Any additional parameters to pass into the URL. 208 | :rtype: :py:class:`filemaker.parser.FMXMLObject` 209 | ''' 210 | self.params.update(kwargs) 211 | return self._commit('edit') 212 | 213 | def new(self, **kwargs): 214 | ''' 215 | Creates a new record using the ``-new`` command. This method 216 | internally calls ``_commit`` and is not chainable. 217 | 218 | :param \**kwargs: Any additional parameters to pass into the URL. 219 | :rtype: :py:class:`filemaker.parser.FMXMLObject` 220 | ''' 221 | self.params.update(kwargs) 222 | return self._commit('new') 223 | 224 | def delete(self, **kwargs): 225 | ''' 226 | Deletes a record using the ``-delete`` command. This method 227 | internally calls ``_commit`` and is not chainable. 228 | 229 | You should have either called the :py:meth:`set_record_id` and/or 230 | :py:meth:`set_modifier_id` methods on the manager, or passed in 231 | ``RECORDID`` or ``MODID`` as params. 232 | 233 | :param \**kwargs: Any additional parameters to pass into the URL. 234 | :rtype: :py:class:`filemaker.parser.FMXMLObject` 235 | ''' 236 | self.params.update(kwargs) 237 | return self._commit('delete') 238 | 239 | def _commit(self, action): 240 | if 'RECORDID' in self.params and not '-recid' in self.params: 241 | self.params['-recid'] = self.params['RECORDID'] 242 | del self.params['RECORDID'] 243 | if 'MODID' in self.params and not '-modid' in self.params: 244 | self.params['-modid'] = self.params['MODID'] 245 | del self.params['MODID'] 246 | data = '&'.join([ 247 | self.dbparams.urlencode(), 248 | self.params.urlencode(), 249 | '-{0}'.format(action), 250 | ]) 251 | try: 252 | resp = requests.post(self.url, auth=self.auth, data=data) 253 | resp.raise_for_status() 254 | except requests.exceptions.RequestException as e: 255 | raise FileMakerConnectionError(e) 256 | return FMXMLObject(resp.content) 257 | 258 | 259 | class Manager(RawManager): 260 | 261 | ''' 262 | A manager for use with :py:class:`filemaker.base.FileMakerModel` classes. 263 | Inherits from the :py:class:`RawManager`, but adds some conveniences and 264 | field mapping methods for use with 265 | :py:class:`filemaker.base.FileMakerModel` sub-classes. 266 | 267 | This manager can be treated as an iterator returning instances of the 268 | relavent :py:class:`filemaker.base.FileMakerModel` sub-class returned from 269 | the FileMaker server. It also supports slicing etc., although negative 270 | indexing is unsupported. 271 | ''' 272 | 273 | def __init__(self, cls): 274 | ''' 275 | :param cls: The :py:class:`filemaker.base.FileMakerModel` sub-class to 276 | use this manager with. It is expected that the model ``meta`` 277 | dictionary will have a ``connection`` key to a dictionary with 278 | values for ``url``, ``db``, and ``layout``. 279 | ''' 280 | self.cls = cls 281 | super(Manager, self).__init__(**self.cls._meta.get('connection')) 282 | self._result_cache = None 283 | self._fm_data = None 284 | 285 | def __iter__(self): 286 | return self.iterator() 287 | 288 | def iterator(self): 289 | if not self._result_cache: 290 | self._result_cache = \ 291 | self.preprocess_resultset(self._get_fm_data().resultset) 292 | for result in self._result_cache: 293 | yield self.cls(result) 294 | 295 | def __len__(self): 296 | return len(self._get_fm_data().resultset) 297 | 298 | def __getitem__(self, k): 299 | mgr = self 300 | if not isinstance(k, (slice,) + six.integer_types): 301 | raise TypeError 302 | assert ((not isinstance(k, slice) and (k >= 0)) 303 | or (isinstance(k, slice) and (k.start is None or k.start >= 0) 304 | and (k.stop is None or k.stop >= 0))), \ 305 | 'Negative indexing is not supported.' 306 | if isinstance(k, slice): 307 | if k.start: 308 | mgr = mgr.set_skip_records(k.start) 309 | if k.stop: 310 | mgr = mgr.set_group_size(k.stop - (k.start or 0)) 311 | return list(mgr)[k] 312 | 313 | def __repr__(self): 314 | return '<{0} query with {1} records...>'.format( 315 | self.cls.__name__, len(self)) 316 | 317 | def _get_fm_data(self): 318 | if self._fm_data is None: 319 | self._fm_data = self.find() 320 | return self._fm_data 321 | 322 | def _clone(self): 323 | mgr = super(Manager, self)._clone() 324 | mgr._result_cache = None 325 | mgr._fm_data = None 326 | return mgr 327 | 328 | def _resolve_fm_field(self, field): 329 | from filemaker.fields import ModelField 330 | parts = field.split('__') 331 | fm_attr_path = [] 332 | klass = self.cls 333 | resolved_field = None 334 | for part in parts: 335 | try: 336 | klass = resolved_field.model if resolved_field else self.cls 337 | except AttributeError: 338 | raise ValueError('Cound not resolve field: {0}'.format(field)) 339 | resolved_field = klass._fields.get(part) 340 | if resolved_field is None: 341 | raise ValueError('Cound not resolve field: {0}'.format(field)) 342 | path = resolved_field.fm_attr.replace('.', '::') 343 | if not path == '+self' and not isinstance( 344 | resolved_field, ModelField): 345 | fm_attr_path.append(path) 346 | return '::'.join(fm_attr_path) 347 | 348 | def preprocess_resultset(self, resultset): 349 | ''' 350 | This is a hook you can override on a manager to pre-process a resultset 351 | from FileMaker before the data is converted into model instances. 352 | 353 | :param resultset: The ``resultset`` attribute of the 354 | :py:class:`filemaker.parser.FMXMLObject` returned from FileMaker 355 | ''' 356 | return resultset 357 | 358 | def all(self): 359 | ''' 360 | A no-op returning a clone of the current manager 361 | ''' 362 | return self._clone() 363 | 364 | def filter(self, **kwargs): 365 | ''' 366 | Filter the queryset by model fields. Model field names are passed in as 367 | arguments rather than FileMaker fields. 368 | 369 | Queries spanning relationships can be made using a ``__``, and 370 | operators can be specified at the end of the query. e.g. Given a model: 371 | :: 372 | 373 | class Foo(FileMakerModel): 374 | beans = fields.IntegerField('FM_Beans') 375 | 376 | meta = { 377 | 'abstract': True, 378 | ... 379 | } 380 | 381 | class Bar(FileMakerModel): 382 | foo = fields.ModelField('BAR_Foo', model=Foo) 383 | num = models.IntegerField('FM_Num')) 384 | 385 | meta = { 386 | 'connection': {...}, 387 | ... 388 | } 389 | 390 | 391 | To find all instances of a ``Bar`` with ``num == 4``: 392 | :: 393 | 394 | Bar.objects.filter(num=4) 395 | 396 | To find all instances of ``Bar`` with ``num < 4``: 397 | :: 398 | 399 | Bar.objects.filter(num__lt=4) 400 | 401 | To Find all instance of ``Bar`` with a ``Foo`` with ``beans == 4``: 402 | :: 403 | 404 | Bar.objects.filter(foo__beans=4) 405 | 406 | To Find all instance of ``Bar`` with a ``Foo`` with ``beans > 4``: 407 | :: 408 | 409 | Bar.objects.filter(foo__beans__gt=4) 410 | 411 | The ``filter`` method is also chainable so you can do: 412 | :: 413 | 414 | Bar.objects.filter(num=4).filter(foo__beans=4) 415 | 416 | :param \**kwargs: The fields and values to filter on. 417 | ''' 418 | mgr = self 419 | for k, v in kwargs.items(): 420 | operator = 'eq' 421 | for op, code in OPERATORS.items(): 422 | if k.endswith('__{0}'.format(op)): 423 | k = re.sub(r'__{0}$'.format(op), '', k) 424 | operator = code 425 | break 426 | try: 427 | mgr = mgr.add_db_param( 428 | self._resolve_fm_field(k), v, op=operator) 429 | except (KeyError, ValueError): 430 | raise ValueError('Invalid filter argument: {0}'.format(k)) 431 | return mgr 432 | 433 | def get(self, **kwargs): 434 | ''' 435 | Returns the first item found by filtering the queryset by ``**kwargs``. 436 | Will raise the ``DoesNotExist`` exception on the managers model class 437 | if no items are found, however, unlike the Django ORM, will silently 438 | return the first result if multiple results are found. 439 | 440 | :param \**kwargs: Field and value queries to be passed to 441 | :py:meth:`filter` 442 | ''' 443 | 444 | try: 445 | return self.filter(**kwargs)[0] 446 | except IndexError: 447 | raise self.cls.DoesNotExist('Could not find item in FileMaker') 448 | 449 | def order_by(self, *args): 450 | ''' 451 | Add an ordering to the queryset with respect to a field. 452 | 453 | If the field name is prepended by a ``-`` that field will be sorted in 454 | reverse. Multiple fields can be specified. 455 | 456 | This method is also chainable so you can do, e.g.: 457 | :: 458 | 459 | Foo.objects.filter(foo='bar').order_by('qux').filter(baz=1) 460 | 461 | :param \*args: The field names to order by. 462 | ''' 463 | mgr = self._clone() 464 | for key in list(mgr.params.keys()): 465 | if key.startswith('-sortfield') or key.startswith('-sortorder'): 466 | mgr.params.pop(key) 467 | i = 0 468 | for arg in args: 469 | if arg.startswith('-'): 470 | mgr = mgr.add_sort_param( 471 | mgr._resolve_fm_field(arg[1:]), 'descend', i) 472 | else: 473 | mgr = mgr.add_sort_param( 474 | mgr._resolve_fm_field(arg), 'ascend', i) 475 | i += 1 476 | return mgr 477 | 478 | def count(self): 479 | ''' 480 | Returns the number of results returned from FileMaker for this query. 481 | ''' 482 | return len(self) 483 | -------------------------------------------------------------------------------- /filemaker/fields.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import datetime 5 | import hashlib 6 | import mimetypes 7 | import re 8 | from decimal import Decimal 9 | 10 | import requests 11 | import urlobject 12 | from dateutil import parser 13 | from django.conf import settings 14 | from django.core import validators 15 | from django.core.exceptions import ValidationError 16 | from django.core.files.storage import default_storage 17 | from django.core.files.uploadedfile import SimpleUploadedFile 18 | from django.template.defaultfilters import slugify 19 | from django.utils import timezone 20 | from django.utils.encoding import (smart_text, smart_bytes, force_text, 21 | python_2_unicode_compatible) 22 | from django.utils.six import string_types, text_type 23 | 24 | from filemaker.exceptions import FileMakerValidationError 25 | from filemaker.validators import validate_gtin 26 | 27 | try: # pragma: no cover 28 | from functools import total_ordering 29 | except ImportError: # pragma: no cover 30 | # Python < 2.7 31 | def total_ordering(cls): # NOQA 32 | 'Class decorator that fills-in missing ordering methods' 33 | convert = { 34 | '__lt__': [('__gt__', lambda self, other: other < self), 35 | ('__le__', lambda self, other: not other < self), 36 | ('__ge__', lambda self, other: not self < other)], 37 | '__le__': [('__ge__', lambda self, other: other <= self), 38 | ('__lt__', lambda self, other: not other <= self), 39 | ('__gt__', lambda self, other: not self <= other)], 40 | '__gt__': [('__lt__', lambda self, other: other > self), 41 | ('__ge__', lambda self, other: not other > self), 42 | ('__le__', lambda self, other: not self > other)], 43 | '__ge__': [('__le__', lambda self, other: other >= self), 44 | ('__gt__', lambda self, other: not other >= self), 45 | ('__lt__', lambda self, other: not self >= other)] 46 | } 47 | if hasattr(object, '__lt__'): 48 | roots = [op for op in convert 49 | if getattr(cls, op) is not getattr(object, op)] 50 | else: 51 | roots = set(dir(cls)) & set(convert) 52 | assert roots, 'must define at least one ordering operation: < > <= >=' 53 | root = max(roots) # prefer __lt __ to __le__ to __gt__ to __ge__ 54 | for opname, opfunc in convert[root]: 55 | if opname not in roots: 56 | opfunc.__name__ = opname 57 | opfunc.__doc__ = getattr(int, opname).__doc__ 58 | setattr(cls, opname, opfunc) 59 | return cls 60 | 61 | try: 62 | from pytz import NonExistentTimeError 63 | except ImportError: 64 | class NonExistentTimeError(Exception): # NOQA 65 | pass 66 | 67 | 68 | @total_ordering 69 | @python_2_unicode_compatible 70 | class BaseFileMakerField(object): 71 | ''' 72 | The base class that all FileMaker fields should inherit from. 73 | 74 | Sub-classes should generally override the coerce method which takes a 75 | value and should return it in the appropriate format. 76 | ''' 77 | 78 | _value = None 79 | name = None 80 | fm_attr = None 81 | validators = [] 82 | min = None 83 | max = None 84 | null_values = [None, ''] 85 | fm_null_value = '' 86 | 87 | def __init__(self, fm_attr=None, *args, **kwargs): 88 | self.fm_attr = fm_attr 89 | self.null = kwargs.pop('null', False) 90 | self.default = kwargs.pop('default', None) 91 | self._value = self.default 92 | self.min = kwargs.pop('min', self.min) 93 | self.max = kwargs.pop('max', self.max) 94 | for key, value in kwargs.items(): 95 | if key == 'fm_attr': 96 | continue 97 | setattr(self, key, value) 98 | 99 | def __str__(self): 100 | return smart_text(self.value) 101 | 102 | def __repr__(self): 103 | return '<{0}: {1}>'.format(self.__class__.__name__, smart_text(self)) 104 | 105 | def __eq__(self, other): 106 | return self.value == other.value 107 | 108 | def __lt__(self, other): 109 | return self.value < other.value 110 | 111 | def __hash__(self): 112 | return '{0}{1}'.format(repr(self), self.name).__hash__() 113 | 114 | def _set_value(self, value): 115 | try: 116 | self._value = self._coerce(value) 117 | except (ValueError, TypeError, UnicodeError): 118 | raise FileMakerValidationError( 119 | '"{0}" is an invalid value for {1} ({2})' 120 | .format(value, self.name, self.__class__.__name__), 121 | field=self 122 | ) 123 | 124 | def _get_value(self): 125 | return self._value 126 | 127 | value = property(_get_value, _set_value) 128 | 129 | def _coerce(self, value): 130 | if value in self.null_values: 131 | value = None 132 | if not self.null and self.default is not None and value is None: 133 | return self.default 134 | if self.null and value is None: 135 | return None 136 | elif value is None: 137 | raise ValueError('{0} cannot be None'.format(self.name)) 138 | value = self.coerce(value) 139 | if self.min is not None and value < self.min: 140 | raise ValueError('{0} must be greater than or equal to {1}.' 141 | .format(self.name, smart_text(self.min))) 142 | if self.max is not None and value > self.max: 143 | raise ValueError('{0} must be less than or equal to {1}.' 144 | .format(self.name, smart_text(self.max))) 145 | if self.validators: 146 | for validator in self.validators: 147 | try: 148 | validator(value) 149 | except ValidationError: 150 | raise FileMakerValidationError( 151 | '"{0}" is an invalid value for {1} ({2})' 152 | .format(value, self.name, self.__class__.__name__), 153 | field=self 154 | ) 155 | return value 156 | 157 | def coerce(self, value): 158 | raise NotImplementedError() 159 | 160 | def to_django(self, *args, **kwargs): 161 | return self.value 162 | 163 | def to_filemaker(self): 164 | return smart_text(self.value) if self.value \ 165 | is not None else self.fm_null_value 166 | 167 | 168 | class UnicodeField(BaseFileMakerField): 169 | ''' 170 | Coerces data into a ``unicode`` object on Python 2.x or a ``str`` object on 171 | Python 3.x 172 | ''' 173 | 174 | def coerce(self, value): 175 | return smart_text(value) 176 | 177 | 178 | class CharField(UnicodeField): 179 | ''' 180 | An alias for :py:class:`UnicodeField`. 181 | ''' 182 | pass 183 | 184 | 185 | class TextField(UnicodeField): 186 | ''' 187 | An alias for :py:class:`UnicodeField`. 188 | ''' 189 | pass 190 | 191 | 192 | class BytesField(BaseFileMakerField): 193 | ''' 194 | Coerces data into a bytestring instance 195 | ''' 196 | 197 | def coerce(self, value): 198 | return smart_bytes(value) 199 | 200 | 201 | class EmailField(CharField): 202 | ''' 203 | A :py:class:`CharField` that vaidates that it's input is a valid email 204 | address. 205 | ''' 206 | 207 | validators = [validators.validate_email] 208 | 209 | 210 | class IPAddressField(CharField): 211 | ''' 212 | A :py:class:`CharField` that validates that it's input is a valid IPv4 213 | or IPv6 address. 214 | ''' 215 | 216 | validators = [validators.validate_ipv46_address] 217 | 218 | 219 | class IPv4AddressField(CharField): 220 | ''' 221 | A :py:class:`CharField` that validates that it's input is a valid IPv4 222 | address. 223 | ''' 224 | 225 | validators = [validators.validate_ipv4_address] 226 | 227 | 228 | class IPv6AddressField(CharField): 229 | ''' 230 | A :py:class:`CharField` that validates that it's input is a valid IPv6 231 | address. 232 | ''' 233 | 234 | validators = [validators.validate_ipv6_address] 235 | 236 | 237 | class IntegerField(BaseFileMakerField): 238 | ''' 239 | Coerces data into an integer. 240 | ''' 241 | 242 | def coerce(self, value): 243 | return int(value) 244 | 245 | 246 | class PositiveIntegerField(IntegerField): 247 | ''' 248 | An :py:class:`IntegerField` that ensures it's input is 0 or greater. 249 | ''' 250 | 251 | min = 0 252 | 253 | 254 | class CommaSeparatedIntegerField(CharField): 255 | ''' 256 | A :py:class:`CharField` that validates a comma separated list of integers 257 | ''' 258 | 259 | validators = [validators.validate_comma_separated_integer_list] 260 | 261 | 262 | class CommaSeparratedIntegerField(CommaSeparatedIntegerField): 263 | ''' 264 | Alternate (misspelled) name for :py:class:`CommaSeparatedIntegerField` 265 | 266 | .. deprecated:: 0.1.1 267 | This field class is deprecated as of 0.1.1 and will disappear in 0.2.0. 268 | Use :py:class:`CommaSeparatedIntegerField` instead. 269 | ''' 270 | 271 | def __init__(self, *args, **kwargs): 272 | import warnings 273 | warnings.warn( 274 | message='CommaSeparratedIntegerField is deprecated. Use ' 275 | 'CommaSeparatedIntegerField.', 276 | category=DeprecationWarning, 277 | ) 278 | super(CommaSeparatedIntegerField, self).__init__(*args, **kwargs) 279 | 280 | 281 | class FloatField(BaseFileMakerField): 282 | ''' 283 | Coerces data into a float. 284 | ''' 285 | 286 | def coerce(self, value): 287 | return float(value) 288 | 289 | 290 | class DecimalField(BaseFileMakerField): 291 | ''' 292 | Coerces data into a decimal.Decimal object. 293 | 294 | :param decimal_places: (*Optional*) The number of decimal places to 295 | truncate input to. 296 | ''' 297 | 298 | decimal_places = None 299 | 300 | def coerce(self, value): 301 | if not isinstance(value, Decimal): 302 | value = Decimal(smart_text(value)) 303 | if self.decimal_places is not None \ 304 | and isinstance(self.decimal_places, int): 305 | quant = '0'.join('' for x in range(self.decimal_places + 1)) 306 | quant = Decimal('.{0}'.format(quant)) 307 | value = value.quantize(quant) 308 | return value 309 | 310 | 311 | @python_2_unicode_compatible 312 | class DateTimeField(BaseFileMakerField): 313 | ''' 314 | Coerces data into a datetime.datetime instance. 315 | 316 | :param strptime: An optional strptime string to use if falling back to the 317 | datetime.datetime.strptime method 318 | ''' 319 | 320 | combine_datetime = datetime.time.min 321 | strptime = None 322 | 323 | def __str__(self): 324 | if self.value: 325 | return smart_text(self.value.isoformat()) 326 | return 'None' 327 | 328 | def coerce(self, value): 329 | combined = False 330 | if isinstance(value, datetime.datetime): 331 | value = value 332 | elif isinstance(value, datetime.date): 333 | combined = True 334 | value = datetime.datetime.combine(value, self.combine_datetime) 335 | elif isinstance(value, string_types) and self.strptime is not None: 336 | value = datetime.datetime.strptime(value, self.strptime) 337 | elif isinstance(value, string_types) and value.strip(): 338 | try: 339 | value = parser.parse(value) 340 | except OverflowError as e: 341 | try: 342 | value = datetime.datetime.strptime(value, '%Y%m%d%H%M%S') 343 | except ValueError: 344 | raise e 345 | elif isinstance(value, (list, tuple)): 346 | value = datetime.datetime(*value) 347 | elif isinstance(value, (int, float)): 348 | value = datetime.datetime.fromtimestamp(value) 349 | else: 350 | raise TypeError('Cannot convert {0} to datetime instance' 351 | .format(type(value))) 352 | if settings.USE_TZ and timezone.is_naive(value): 353 | try: 354 | tz = timezone.get_current_timezone() 355 | if combined: 356 | # If we combined the date with datetime.time.min we 357 | # should adjust by dst to get the correct datetime 358 | value += tz.dst(value) 359 | value = timezone.make_aware( 360 | value, timezone.get_current_timezone()) 361 | except NonExistentTimeError: 362 | value = timezone.get_current_timezone().localize(value) 363 | value = timezone.utc.normalize(value) 364 | elif not settings.USE_TZ and timezone.is_aware(value): 365 | value = timezone.make_naive(value, timezone.get_current_timezone()) 366 | return value 367 | 368 | def to_filemaker(self): 369 | return getattr(self.value, 'isoformat', lambda: '')() 370 | 371 | 372 | class DateField(DateTimeField): 373 | ''' 374 | Coerces data into a datetime.date instance. 375 | 376 | :param strptime: An optional strptime string to use if falling back to the 377 | datetime.datetime.strptime method 378 | ''' 379 | 380 | def coerce(self, value): 381 | dt = super(DateField, self).coerce(value) 382 | if timezone.is_aware(dt): 383 | dt = timezone.get_current_timezone().normalize(dt) 384 | return dt.date() 385 | 386 | 387 | class BooleanField(BaseFileMakerField): 388 | ''' 389 | Coerces data into a boolean. 390 | 391 | :param map: An optional dictionary mapping that maps values to their 392 | Boolean counterparts. 393 | ''' 394 | 395 | def __init__(self, fm_attr=None, *args, **kwargs): 396 | self.map = kwargs.pop('map', {}) 397 | self.reverse_map = dict((v, k) for k, v in self.map.items()) 398 | return super(BooleanField, self).__init__( 399 | fm_attr=fm_attr, *args, **kwargs) 400 | 401 | def coerce(self, value): 402 | if value in list(self.map.keys()): 403 | return self.map.get(value) 404 | if isinstance(value, bool): 405 | return value 406 | elif isinstance(value, (int, float)): 407 | return not value == 0 408 | elif isinstance(value, string_types): 409 | value = value.strip().lower() 410 | if value in ['y', 'yes', 'true', 't', '1']: 411 | return True 412 | return False 413 | else: 414 | return bool(value) 415 | 416 | def to_filemaker(self): 417 | if self.value in self.reverse_map: 418 | return self.reverse_map.get(self.value) 419 | return force_text(self.value).lower() if self.value \ 420 | is not None else self.fm_null_value 421 | 422 | 423 | class NullBooleanField(BooleanField): 424 | ''' 425 | A BooleanField that also accepts a null value 426 | ''' 427 | 428 | null = True 429 | 430 | def coerce(self, value): 431 | if value is None: 432 | return None 433 | if value in ('None',): 434 | return None 435 | return super(NullBooleanField, self).coerce(value) 436 | 437 | 438 | class ListField(BaseFileMakerField): 439 | ''' 440 | A field that takes a list of values of other types. 441 | 442 | :param base_type: The base field type to use. 443 | ''' 444 | 445 | base_type = None 446 | 447 | def __init__(self, fm_attr=None, *args, **kwargs): 448 | self.base_type = kwargs.pop('base_type', None) 449 | if self.base_type is None: 450 | raise ValueError('You must specify a base_type') 451 | return super(ListField, self)\ 452 | .__init__(fm_attr=fm_attr, *args, **kwargs) 453 | 454 | def coerce(self, value): 455 | values = [] 456 | for val in value: 457 | sub_type = self.base_type() 458 | sub_type.value = val 459 | values.append(sub_type.value) 460 | return values 461 | 462 | def to_django(self, *args, **kwargs): 463 | try: 464 | return [v.to_django(*args, **kwargs) for v in self.value] 465 | except AttributeError: 466 | return self.value 467 | 468 | def to_filemaker(self): 469 | values = [] 470 | for val in self.value: 471 | sub_type = self.base_type() 472 | sub_type.value = val 473 | values.append(sub_type.to_filemaker()) 474 | return values 475 | 476 | 477 | class ModelField(BaseFileMakerField): 478 | ''' 479 | A field that provides a refernce to an instance of another filemaker model, 480 | equivalent to a Django ForeignKey. 481 | 482 | :param model: The FileMaker model to reference. 483 | ''' 484 | 485 | model = None 486 | 487 | def __init__(self, fm_attr=None, *args, **kwargs): 488 | self.model = kwargs.pop('model', None) 489 | if self.model is None: 490 | raise ValueError('You must specify a model') 491 | return super(ModelField, self)\ 492 | .__init__(fm_attr=fm_attr, *args, **kwargs) 493 | 494 | def coerce(self, value): 495 | try: 496 | return self.model(value) 497 | except FileMakerValidationError: 498 | if self.default: 499 | return self.default 500 | if self.null: 501 | return None 502 | raise 503 | 504 | def to_django(self, *args, **kwargs): 505 | if self.value: 506 | return self.value.to_django(*args, **kwargs) 507 | return None 508 | 509 | def to_filemaker(self): 510 | if self.value: 511 | return self.value.to_filemaker() 512 | return '' 513 | 514 | def contribute_to_class(self, cls): 515 | self.model._meta['related'].append((cls, self.name)) 516 | 517 | 518 | class ToOneField(ModelField): 519 | ''' 520 | An alias for :py:class:`ModelField` 521 | ''' 522 | pass 523 | 524 | 525 | class ModelListField(BaseFileMakerField): 526 | ''' 527 | A fields that gives a reference to a list of models, equivalent to a 528 | Django ManyToMany relation. 529 | 530 | :param model: The model class to reference. 531 | ''' 532 | 533 | model = None 534 | 535 | def __init__(self, fm_attr=None, *args, **kwargs): 536 | self.model = kwargs.pop('model', None) 537 | if self.model is None: 538 | raise ValueError('You must specify a model') 539 | return super(ModelListField, self)\ 540 | .__init__(fm_attr=fm_attr, *args, **kwargs) 541 | 542 | def coerce(self, value): 543 | instances = [] 544 | for v in value: 545 | instances.append(self.model(v) 546 | if not isinstance(v, self.model) 547 | else v) 548 | return instances 549 | 550 | def to_django(self, *args, **kwargs): 551 | return [m.to_django(*args, **kwargs) for m in self.value] 552 | 553 | def to_filemaker(self): 554 | return [m.to_filemaker() for m in self.value] 555 | 556 | def contribute_to_class(self, cls): 557 | self.model._meta['many_related'].append((cls, self.name)) 558 | 559 | 560 | class ToManyField(ModelListField): 561 | ''' 562 | An alias for :py:class:`ModelListField`. 563 | ''' 564 | pass 565 | 566 | 567 | @python_2_unicode_compatible 568 | class PercentageField(DecimalField): 569 | ''' 570 | A :py:class:`DecimalField` that ensures it's input is between 0 and 100 571 | ''' 572 | 573 | min = Decimal('0') 574 | max = Decimal('100') 575 | 576 | def __str__(self): 577 | return '{0}%'.format(smart_text(self.value)) 578 | 579 | 580 | class CurrencyField(DecimalField): 581 | ''' 582 | A decimal field that uses 2 decimal places and strips off any currency 583 | symbol from the start of it's input. 584 | 585 | Has a default minimum value of ``0.00``. 586 | ''' 587 | 588 | min = Decimal('0.00') 589 | decimal_places = 2 590 | 591 | def coerce(self, value): 592 | if isinstance(value, string_types): 593 | symbols = [ 594 | '¤', '؋', '฿', 'B/.', 'Bs.', 'Bs.F.', 'GH¢', '¢', 'Ch.', '₡', 595 | 'D', 'ден', 'دج', '.د.ب', 'د.ع', 'د.ك', 'ل.د', 'дин', 'د.ت', 596 | 'د.م.', 'د.إ', '\$', '[a-zA-z]{1,3}\$', '\$[a-zA-Z]{1,3}', 597 | '元', '圓', '元', '圓', '₫', '€', '€', 'ƒ', 'Afl.', 'NAƒ', 598 | 'FCFA', '₣', 'G₣', 'S₣', 'Fr.', '₲', '₴', '₭', 'Kč', 'Íkr', 599 | 'K.D.', 'ლ', 'm.', '₥', '₦', 'Nu.', '₱', '£', '₤', 600 | '[a-zA-Z]{1,2}[£₤]', 'ج.م.', 'Pt.', 'ريال', 'ر.ع.', 'ر.ق', 601 | 'ر.س', 'ریال', '៛', '₹', '₹', '₨', '₪', 'KSh', 'Sh.So.', 602 | 'S/.', 'лв', 'сом', '৳', '₸', '₮', 'VT', '₩', '¥', '円', '圓', 603 | '元', '圆', 'zł', '₳', '₢', '₰', '₯', '₠', 'ƒ', '₣', '₤', 604 | 'Kčs', 'ℳ', '₧', 'ℛℳ', '₷', '₶', '[a-zA-Z]{1,4}', 605 | ] 606 | value = re.sub( 607 | r'^({0})'.format(r'|'.join(symbols)), '', value.strip() 608 | ).strip() 609 | return super(CurrencyField, self).coerce(value) 610 | 611 | 612 | class SlugField(CharField): 613 | ''' 614 | A :py:class:`CharField` that validates it's input is a valid slug. 615 | Will automatically slugify it's input, by default. 616 | Can also be passed a specific slugify function. 617 | 618 | .. note:: 619 | 620 | If the custom ``slugify`` function would create a slug that would fail 621 | a test by ``django.core.validators.validate_slug`` it may be wise to 622 | pass in a different or empty ``validators`` list. 623 | 624 | :param auto: Whether to slugify input. Defaults to ``True``. 625 | :param slugify: The slugify function to use. Defaults to 626 | ``django.template.defaultfilters.slugify``. 627 | ''' 628 | 629 | validators = [validators.validate_slug] 630 | 631 | def __init__(self, fm_attr=None, *args, **kwargs): 632 | self.slugify = kwargs.pop('slugify', slugify) 633 | self.auto = kwargs.pop('auto', True) 634 | return super(SlugField, self)\ 635 | .__init__(fm_attr=fm_attr, *args, **kwargs) 636 | 637 | def coerce(self, value): 638 | value = super(SlugField, self).coerce(value) 639 | if self.auto: 640 | value = self.slugify(value) 641 | return value 642 | 643 | 644 | class GTINField(CharField): 645 | ''' 646 | A :py:class:`CharField` that validates it's input is a valid 647 | `GTIN `_. 648 | ''' 649 | 650 | validators = [validate_gtin] 651 | 652 | 653 | class URLField(CharField): 654 | ''' 655 | A :py:class:`CharField` that validates it's input is a valid URL. 656 | ''' 657 | 658 | validators = [validators.URLValidator()] 659 | 660 | 661 | class FileField(BaseFileMakerField): 662 | ''' 663 | A field that downloads file data (e.g. from the FileMaker web interface). 664 | The file will be saved with a filename that is the combination of the 665 | hash of it's contents, and the extension associated with the mimetype it 666 | was served with. 667 | 668 | Can be given an optional ``base_url`` with which the URL received from 669 | FileMaker will be joined. 670 | 671 | :param retries: The number of retries to make when downloading the file in 672 | case of errors. Defaults to ``5``. 673 | :param base_url: The URL with which to combine the url received from 674 | FileMaker, empty by default. 675 | :param storage: The Django storage class to use when saving the file. 676 | Defaults to the default storage class. 677 | ''' 678 | 679 | retries = 5 680 | base_url = '' 681 | storage = default_storage 682 | 683 | def __init__(self, fm_attr=None, *args, **kwargs): 684 | self.base_url = urlobject.URLObject(kwargs.pop('base_url', '')) 685 | return super(FileField, self)\ 686 | .__init__(fm_attr=fm_attr, *args, **kwargs) 687 | 688 | def _get_http(self, url): 689 | try: 690 | r = requests.get(url.without_auth(), auth=url.auth) 691 | r.raise_for_status() 692 | return r.content, \ 693 | r.headers.get('Content-Type', '').split(';')[0] 694 | except requests.RequestException: 695 | return None, None 696 | 697 | def _get_file(self, url): 698 | content, mime = None, None 699 | for i in range(self.retries): 700 | if url.scheme in ('http', 'https'): 701 | get = self._get_http 702 | else: 703 | raise FileMakerValidationError( 704 | 'Unable to obtain file via "{0}"'.format(url.scheme)) 705 | content, mime = get(url) 706 | if content is not None and mime is not None: 707 | break 708 | if content is None or mime is None: 709 | raise FileMakerValidationError( 710 | 'Could not get file from: {0}'.format(url)) 711 | if mime in ('image/jpeg', 'image/jpe', 'image/jpg'): 712 | mime = 'image/jpg' 713 | fname = '{0}{1}'.format( 714 | hashlib.md5(smart_bytes(content)).hexdigest()[:20], 715 | mimetypes.guess_extension(mime, strict=False), 716 | ) 717 | return {'filename': fname, 'content': content, 'content-type': mime} 718 | 719 | def coerce(self, value): 720 | url = urlobject.URLObject(smart_text(value or '')) 721 | try: 722 | if not url.scheme: 723 | url = url.with_scheme(self.base_url.scheme or '') 724 | if not url.hostname: 725 | url = url.with_hostname(self.base_url.hostname or '') 726 | if url.auth == (None, None) \ 727 | and not self.base_url.auth == (None, None): 728 | url = url.with_auth(*self.base_url.auth) 729 | except (TypeError, ValueError): # pragma: no cover 730 | raise FileMakerValidationError('Could not determine file url.') 731 | return self._get_file(url) 732 | 733 | def to_django(self, *args, **kwargs): 734 | return SimpleUploadedFile.from_dict(self.value) if self.value else None 735 | 736 | def to_filemaker(self): 737 | return self.storage.url(self.value['filename']) \ 738 | if self.value is not None and self.value.get('filename', None) \ 739 | else self.fm_null_value 740 | 741 | 742 | class ImageField(FileField): 743 | ''' 744 | A :py:class:`FileField` that expects the mimetype of the received file to 745 | be ``image/*``. 746 | ''' 747 | 748 | def _get_file(self, url): 749 | f = super(ImageField, self)._get_file(url) 750 | if not f.content_type.split('/')[0] == 'image': 751 | raise FileMakerValidationError( 752 | '"{0}" is not a valid image type.'.format(f.content_type)) 753 | return f 754 | 755 | 756 | class UploadedFileField(BaseFileMakerField): 757 | ''' 758 | Takes the path of a file that has already been uploaded to storage and 759 | generates a File instance from it. 760 | 761 | :param storage: An instance of the storage class to use 762 | ''' 763 | 764 | storage = default_storage 765 | 766 | def coerce(self, value): 767 | value = re.sub( 768 | r'^[\s\/\.]+', '', text_type(urlobject.URLObject(value).path)) 769 | try: 770 | f = self.storage.open(value) 771 | except (Exception, EnvironmentError): 772 | raise FileMakerValidationError( 773 | 'Could not open file "{0}".'.format(value)) 774 | else: 775 | return {'file': f, 'filename': f.name} 776 | 777 | def to_django(self, *args, **kwargs): 778 | return self.value['file'] if self.value else None 779 | 780 | def to_filemaker(self): 781 | return self.storage.url(self.value['filename']) \ 782 | if self.value else self.fm_null_value 783 | -------------------------------------------------------------------------------- /filemaker/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import datetime 5 | import itertools 6 | import os 7 | import platform 8 | import time 9 | from decimal import Decimal 10 | 11 | import urlobject 12 | from django.contrib.redirects.models import Redirect 13 | from django.contrib.sites.models import Site 14 | from django.core.management import call_command 15 | from django.db.models.fields import CharField, IntegerField 16 | from django.http import QueryDict 17 | from django.test import TransactionTestCase 18 | from django.test.utils import override_settings 19 | from django.utils import timezone 20 | from django.utils.six import text_type 21 | from django.utils.unittest import skipIf, skipUnless 22 | from httpretty import httprettified, HTTPretty 23 | from mock import Mock, NonCallableMock, NonCallableMagicMock, patch, MagicMock 24 | 25 | from filemaker import fields, FileMakerValidationError, FileMakerModel 26 | from filemaker.base import deep_getattr 27 | from filemaker.exceptions import FileMakerServerError 28 | from filemaker.manager import RawManager, Manager 29 | from filemaker.parser import FMXMLObject, FMDocument 30 | from filemaker.utils import get_field_class 31 | 32 | try: 33 | from django.utils.encoding import force_bytes 34 | except ImportError: 35 | # Django 1.4.x 36 | from django.utils.encoding import smart_bytes as force_bytes # NOQA 37 | 38 | 39 | class TestFilemakerFields(TransactionTestCase): 40 | 41 | def test_default_and_null(self): 42 | f = fields.IntegerField(null=True) 43 | f.value = None 44 | self.assertEqual(f.value, None) 45 | f.value = 3 46 | self.assertEqual(f.value, 3) 47 | f = fields.IntegerField(default=2) 48 | self.assertEqual(f.value, 2) 49 | f.value = None 50 | self.assertEqual(f.value, 2) 51 | f.value = 3 52 | self.assertEqual(f.value, 3) 53 | f = fields.IntegerField(default=2, null=True) 54 | self.assertEqual(f.value, 2) 55 | f.value = None 56 | self.assertEqual(f.value, None) 57 | f.value = 3 58 | self.assertEqual(f.value, 3) 59 | f = fields.IntegerField(null=False, default=None) 60 | with self.assertRaises(FileMakerValidationError): 61 | f.value = None 62 | 63 | def test_min_max(self): 64 | f = fields.IntegerField(min=2) 65 | with self.assertRaises(FileMakerValidationError): 66 | f.value = 1 67 | f.value = 3 68 | self.assertEqual(f.value, 3) 69 | f = fields.IntegerField(max=2) 70 | with self.assertRaises(FileMakerValidationError): 71 | f.value = 3 72 | f.value = 1 73 | self.assertEqual(f.value, 1) 74 | f = fields.IntegerField(min=2, max=3) 75 | with self.assertRaises(FileMakerValidationError): 76 | f.value = 1 77 | with self.assertRaises(FileMakerValidationError): 78 | f.value = 4 79 | f.value = 2 80 | self.assertEqual(f.value, 2) 81 | f.value = 3 82 | self.assertEqual(f.value, 3) 83 | 84 | def test_comparison(self): 85 | f1 = fields.IntegerField() 86 | f2 = fields.IntegerField() 87 | f1.value = 1 88 | f2.value = 1 89 | self.assertEqual(f1, f2) 90 | f2.value = 2 91 | self.assertGreater(f2, f1) 92 | self.assertLess(f1, f2) 93 | f2 = fields.DecimalField() 94 | f2.value = '1' 95 | self.assertEqual(f1, f2) 96 | 97 | def test_hash(self): 98 | f1 = fields.IntegerField() 99 | f2 = fields.IntegerField() 100 | self.assertEqual(hash(f1), hash(f2)) 101 | f1.value = 1 102 | self.assertNotEqual(hash(f1), hash(f2)) 103 | f2.value = 1 104 | self.assertEqual(hash(f1), hash(f2)) 105 | f1.name = 'test' 106 | self.assertNotEqual(hash(f1), hash(f2)) 107 | {f1: 123} 108 | 109 | def test_text_methods(self): 110 | f = fields.CharField() 111 | f.value = 'abc' 112 | self.assertEqual(text_type(f), 'abc') 113 | self.assertIn('abc', repr(f)) 114 | f = fields.PercentageField() 115 | f.value = '20' 116 | self.assertEqual(text_type(f), '20%') 117 | self.assertIn('20%', repr(f)) 118 | f = fields.DateTimeField() 119 | self.assertTrue(text_type(f)) 120 | now = timezone.now() 121 | f.value = now 122 | self.assertEqual(text_type(f), now.isoformat()) 123 | self.assertIn(now.isoformat(), repr(f)) 124 | 125 | def test_to_django(self): 126 | f = fields.IntegerField(null=True) 127 | f.value = 3 128 | self.assertEqual(f.to_django(), 3) 129 | 130 | def test_unicode_field(self): 131 | f = fields.UnicodeField() 132 | f.value = 2 133 | self.assertEqual(f.value, '2') 134 | self.assertTrue(isinstance(f.value, text_type)) 135 | f.value = b'abc' 136 | self.assertEqual(f.value, 'abc') 137 | self.assertTrue(isinstance(f.value, text_type)) 138 | 139 | def test_unicode_synonyms(self): 140 | self.assertTrue(issubclass(fields.CharField, fields.UnicodeField)) 141 | self.assertTrue(issubclass(fields.TextField, fields.UnicodeField)) 142 | 143 | def test_bytes_field(self): 144 | f = fields.BytesField() 145 | f.value = 2 146 | self.assertEqual(f.value, b'2') 147 | self.assertTrue(isinstance(f.value, bytes)) 148 | f.value = 'abc' 149 | self.assertEqual(f.value, b'abc') 150 | self.assertTrue(isinstance(f.value, bytes)) 151 | 152 | def test_integer_field(self): 153 | f = fields.IntegerField() 154 | f.value = 1 155 | self.assertEqual(f.value, 1) 156 | f.value = 0b1 157 | self.assertEqual(f.value, 1) 158 | f.value = 0x1 159 | self.assertEqual(f.value, 1) 160 | f.value = 0o1 161 | self.assertEqual(f.value, 1) 162 | f.value = '1' 163 | self.assertEqual(f.value, 1) 164 | f.value = 1.0 165 | self.assertEqual(f.value, 1) 166 | with self.assertRaises(FileMakerValidationError): 167 | f.value = 'a' 168 | 169 | def test_float_field(self): 170 | f = fields.FloatField() 171 | f.value = 1 172 | self.assertEqual(f.value, 1.0) 173 | f.value = 0b1 174 | self.assertEqual(f.value, 1.0) 175 | f.value = 0x1 176 | self.assertEqual(f.value, 1.0) 177 | f.value = 0o1 178 | self.assertEqual(f.value, 1.0) 179 | f.value = '1' 180 | self.assertEqual(f.value, 1.0) 181 | f.value = '1.0' 182 | self.assertEqual(f.value, 1.0) 183 | f.value = 1.0 184 | self.assertEqual(f.value, 1.0) 185 | with self.assertRaises(FileMakerValidationError): 186 | f.value = 'a' 187 | 188 | def test_decimal_field(self): 189 | f = fields.FloatField() 190 | f.value = Decimal('1') 191 | self.assertEqual(f.value, Decimal('1')) 192 | f.value = 1 193 | self.assertEqual(f.value, Decimal('1')) 194 | f.value = 0b1 195 | self.assertEqual(f.value, Decimal('1')) 196 | f.value = 0x1 197 | self.assertEqual(f.value, Decimal('1')) 198 | f.value = 0o1 199 | self.assertEqual(f.value, Decimal('1')) 200 | f.value = '1' 201 | self.assertEqual(f.value, Decimal('1')) 202 | f.value = '1.0' 203 | self.assertEqual(f.value, Decimal('1.0')) 204 | f.value = 1.0 205 | self.assertEqual(f.value, Decimal('1.0')) 206 | with self.assertRaises(FileMakerValidationError): 207 | f.value = 'a' 208 | 209 | def test_decimal_field_decimal_places(self): 210 | f = fields.DecimalField(decimal_places=2) 211 | f.value = Decimal('1.219') 212 | self.assertEqual(f.value, Decimal('1.22')) 213 | f = fields.DecimalField(decimal_places=5) 214 | f.value = Decimal('1.219') 215 | self.assertEqual(f.value, Decimal('1.21900')) 216 | 217 | @override_settings(USE_TZ=False) 218 | def test_datetime_field_naive(self): 219 | now = timezone.now() 220 | assert timezone.is_naive(now) 221 | aware = timezone.make_aware(now, timezone.get_current_timezone()) 222 | f = fields.DateTimeField() 223 | f.value = aware 224 | self.assertEqual(now, f.value) 225 | 226 | @override_settings(USE_TZ=True) 227 | def test_datetime_field_aware(self): 228 | now = timezone.now() 229 | assert timezone.is_aware(now) 230 | naive = timezone.make_naive(now, timezone.get_current_timezone()) 231 | f = fields.DateTimeField() 232 | f.value = naive 233 | self.assertEqual(now, f.value) 234 | 235 | def test_datetime_field_combine(self): 236 | now = timezone.now() 237 | f = fields.DateTimeField() 238 | f.value = now.date() 239 | self.assertEqual(f.value.date(), now.date()) 240 | self.assertTrue(isinstance(f.value, datetime.datetime)) 241 | self.assertEqual(f.value.time(), datetime.time.min) 242 | f = fields.DateTimeField(combine_datetime=datetime.time.max) 243 | f.value = now.date() 244 | self.assertEqual(f.value.date(), now.date()) 245 | self.assertTrue(isinstance(f.value, datetime.datetime)) 246 | self.assertEqual(f.value.time(), datetime.time.max) 247 | f = fields.DateTimeField(combine_datetime=datetime.time(12, 30)) 248 | f.value = now.date() 249 | self.assertEqual(f.value.date(), now.date()) 250 | self.assertTrue(isinstance(f.value, datetime.datetime)) 251 | self.assertEqual(f.value.time(), datetime.time(12, 30)) 252 | 253 | def test_datetime_field_strptime(self): 254 | f = fields.DateTimeField(strptime='%Y %d %m %H') 255 | f.value = '2012 11 02 12' 256 | d = f.value 257 | self.assertEqual(d.year, 2012) 258 | self.assertEqual(d.month, 2) 259 | self.assertEqual(d.day, 11) 260 | self.assertEqual(d.hour, 12) 261 | self.assertTrue(isinstance(d, datetime.datetime)) 262 | 263 | def test_datetime_field_parse(self): 264 | f = fields.DateTimeField() 265 | f.value = '2012 Jan 3rd 12:30' 266 | d = f.value 267 | self.assertEqual(d.year, 2012) 268 | self.assertEqual(d.month, 1) 269 | self.assertEqual(d.day, 3) 270 | self.assertEqual(d.hour, 12) 271 | self.assertEqual(d.minute, 30) 272 | self.assertTrue(isinstance(d, datetime.datetime)) 273 | f.value = '20120103123002' 274 | d = f.value 275 | self.assertEqual(d.year, 2012) 276 | self.assertEqual(d.month, 1) 277 | self.assertEqual(d.day, 3) 278 | self.assertEqual(d.hour, 12) 279 | self.assertEqual(d.minute, 30) 280 | self.assertEqual(d.second, 2) 281 | self.assertTrue(isinstance(d, datetime.datetime)) 282 | 283 | @skipIf( 284 | platform.python_implementation().lower() == 'pypy', 285 | 'PyPy won\'t throw the expected OverflowError in this case.' 286 | ) 287 | @override_settings(USE_TZ=False) 288 | def test_datetime_parse_overflow(self): 289 | ''' 290 | We get a problem with a particular datetime format using dateutils 291 | parse. We should recover using strptime. 292 | ''' 293 | f = fields.DateTimeField() 294 | f.value = '2012622100000' 295 | self.assertEqual(f.value, datetime.datetime(2012, 6, 22, 10, 0)) 296 | 297 | def test_datetime_field_iterable(self): 298 | f = fields.DateTimeField() 299 | f.value = (2012, 1, 3, 12, 30, 2) 300 | d = f.value 301 | self.assertEqual(d.year, 2012) 302 | self.assertEqual(d.month, 1) 303 | self.assertEqual(d.day, 3) 304 | self.assertEqual(d.hour, 12) 305 | self.assertEqual(d.minute, 30) 306 | self.assertEqual(d.second, 2) 307 | self.assertTrue(isinstance(d, datetime.datetime)) 308 | f.value = [2012, 1, 3, 12, 30, 2] 309 | self.assertEqual(f.value, d) 310 | 311 | def test_datetime_field_number(self): 312 | now = timezone.now().replace(microsecond=0) 313 | now_t = time.mktime(now.timetuple()) 314 | f = fields.DateTimeField() 315 | f.value = now_t 316 | self.assertEqual(f.value, now) 317 | now_t = int(now_t) 318 | f.value = now_t 319 | self.assertEqual(f.value, now) 320 | 321 | def test_datetime_field_raises(self): 322 | f = fields.DateTimeField() 323 | with self.assertRaises(FileMakerValidationError): 324 | f.value = {} 325 | 326 | def test_datetime_field_raises_on_empty_string(self): 327 | # dateutil's parser will turn an empty string into now, we don't 328 | # want this 329 | f = fields.DateTimeField() 330 | with self.assertRaises(FileMakerValidationError): 331 | f.value = '' 332 | 333 | def test_datetime_field_to_filemaker(self): 334 | f = fields.DateTimeField(null=True) 335 | f.value = None 336 | self.assertEqual(f.to_filemaker(), '') 337 | now = timezone.now() 338 | f.value = now 339 | self.assertEqual(f.to_filemaker(), now.isoformat()) 340 | 341 | @override_settings(USE_TZ=False) 342 | def test_date_field_no_tz(self): 343 | f = fields.DateField() 344 | now = timezone.now() 345 | f.value = now 346 | self.assertEqual(f.value, now.date()) 347 | 348 | def test_date_field_to_filemaker(self): 349 | f = fields.DateField(null=True) 350 | f.value = None 351 | self.assertEqual(f.to_filemaker(), '') 352 | now = timezone.now().date() 353 | f.value = now 354 | self.assertEqual(f.to_filemaker(), now.isoformat()) 355 | 356 | @override_settings(USE_TZ=True) 357 | def test_date_field(self): 358 | f = fields.DateField() 359 | now = timezone.now() 360 | f.value = now 361 | self.assertEqual(f.value, now.date()) 362 | 363 | def test_boolean_field(self): 364 | f = fields.BooleanField() 365 | for val in [True, 1, -1, 1., 'y', 'yes', 'true', 't', '1', [1], (1,)]: 366 | f.value = val 367 | self.assertTrue(f.value) 368 | for val in [False, 0, 0., 'n', 'no', 'false', 'f', '0', [], ()]: 369 | f.value = val 370 | self.assertFalse(f.value) 371 | 372 | def test_boolean_field_map(self): 373 | f = fields.BooleanField(map={'ja': True, 'nein': False}) 374 | f.value = 'ja' 375 | self.assertTrue(f.value) 376 | f.value = 'nein' 377 | self.assertFalse(f.value) 378 | 379 | def test_null_boolean_field(self): 380 | f = fields.NullBooleanField() 381 | self.assertEqual(f.coerce(None), None) 382 | self.assertEqual(f.coerce('None'), None) 383 | 384 | def test_boolean_field_to_filemaker(self): 385 | f = fields.BooleanField(null=True) 386 | f.value = None 387 | self.assertEqual(f.to_filemaker(), '') 388 | f.value = True 389 | self.assertEqual(f.to_filemaker(), 'true') 390 | f.value = False 391 | self.assertEqual(f.to_filemaker(), 'false') 392 | f = fields.BooleanField(map={'ja': True, 'nein': False}) 393 | f.value = True 394 | self.assertEqual(f.to_filemaker(), 'ja') 395 | f.value = False 396 | self.assertEqual(f.to_filemaker(), 'nein') 397 | 398 | def test_list_field(self): 399 | with self.assertRaises(ValueError): 400 | f = fields.ListField() 401 | f = fields.ListField(base_type=fields.IntegerField) 402 | with self.assertRaises(FileMakerValidationError): 403 | f.value = ['a', 'b', 'c'] 404 | f.value = [1, 2, 3] 405 | self.assertEqual(f.value, [1, 2, 3]) 406 | self.assertEqual(f.to_django(), [1, 2, 3]) 407 | 408 | def test_list_field_to_filemaker(self): 409 | f = fields.ListField(base_type=fields.IntegerField) 410 | f.value = [1, 2, 3] 411 | self.assertEqual(f.to_filemaker(), ['1', '2', '3']) 412 | 413 | def test_model_field(self): 414 | class TestFMModel(FileMakerModel): 415 | name = fields.CharField('name') 416 | value = fields.IntegerField('value') 417 | 418 | fm_value = Mock() 419 | fm_value.name = 'Name' 420 | fm_value.value = 123 421 | f = fields.ModelField(model=TestFMModel) 422 | f.value = fm_value 423 | self.assertEqual(f.value.name, 'Name') 424 | self.assertEqual(f.value.value, 123) 425 | fm_value.value = 'a' 426 | with self.assertRaises(FileMakerValidationError): 427 | f.value = fm_value 428 | f.null = True 429 | f.value = fm_value 430 | self.assertEqual(f.value, None) 431 | f.default = 1 432 | f.value = fm_value 433 | self.assertEqual(f.value, 1) 434 | 435 | def test_model_field_to_filemaker(self): 436 | class TestFMModel(FileMakerModel): 437 | name = fields.CharField('name') 438 | value = fields.IntegerField('value') 439 | 440 | def to_filemaker(self): 441 | return self.name, self.value 442 | 443 | fm_value = Mock() 444 | fm_value.name = 'Name' 445 | fm_value.value = 123 446 | f = fields.ModelField(model=TestFMModel) 447 | f.value = fm_value 448 | self.assertEqual(f.to_filemaker(), ('Name', 123)) 449 | 450 | def test_model_list_field(self): 451 | class TestFMModel(FileMakerModel): 452 | name = fields.CharField('name') 453 | value = fields.IntegerField('value') 454 | 455 | fm_value = Mock() 456 | fm_value.name = 'Name' 457 | fm_value.value = 123 458 | f = fields.ModelListField(model=TestFMModel) 459 | f.value = [fm_value, fm_value] 460 | self.assertEqual(len(f.value), 2) 461 | for val in f.value: 462 | self.assertEqual(val.name, 'Name') 463 | self.assertEqual(val.value, 123) 464 | fm_value.value = 'a' 465 | with self.assertRaises(FileMakerValidationError): 466 | f.value = [fm_value, fm_value] 467 | 468 | def test_model_list_field_to_filemaker(self): 469 | class TestFMModel(FileMakerModel): 470 | name = fields.CharField('name') 471 | value = fields.IntegerField('value') 472 | 473 | def to_filemaker(self): 474 | return self.name, self.value 475 | 476 | fm_value = Mock() 477 | fm_value.name = 'Name' 478 | fm_value.value = 123 479 | f = fields.ModelListField(model=TestFMModel) 480 | f.value = [fm_value] 481 | self.assertEqual(f.to_filemaker(), [('Name', 123)]) 482 | 483 | def test_rel_synonyms(self): 484 | self.assertTrue(issubclass(fields.ToOneField, fields.ModelField)) 485 | self.assertTrue(issubclass(fields.ToManyField, fields.ModelListField)) 486 | 487 | def test_percentage_field(self): 488 | f = fields.PercentageField() 489 | with self.assertRaises(FileMakerValidationError): 490 | f.value = '-1' 491 | with self.assertRaises(FileMakerValidationError): 492 | f.value = '101' 493 | f.value = '50' 494 | self.assertIn('%', text_type(f)) 495 | 496 | def test_currency_field(self): 497 | f = fields.CurrencyField() 498 | f.value = '2' 499 | self.assertEqual(f.value, Decimal('2.00')) 500 | f.value = '$19.99' 501 | self.assertEqual(f.value, Decimal('19.99')) 502 | with self.assertRaises(FileMakerValidationError): 503 | f.value = '-1' 504 | 505 | def test_slug_field(self): 506 | f = fields.SlugField() 507 | f.value = 'A Test Slug' 508 | self.assertEqual(f.value, 'a-test-slug') 509 | f = fields.SlugField(auto=False) 510 | with self.assertRaises(FileMakerValidationError): 511 | f.value = 'a %1 )(' 512 | f.value = 'a-test-slug' 513 | self.assertEqual(f.value, 'a-test-slug') 514 | f = fields.SlugField(slugify=lambda x: 'aaa') 515 | f.value = 'Test Value' 516 | self.assertEqual(f.value, 'aaa') 517 | 518 | def test_null_values(self): 519 | # This should raise a value error because for a decimal field '' 520 | # is a null value and null is not True 521 | f = fields.DecimalField() 522 | with self.assertRaises(FileMakerValidationError): 523 | f.value = '' 524 | # And this should be allowed, and return a value of None 525 | f = fields.DecimalField(null=True) 526 | f.value = '' 527 | self.assertEqual(f.value, None) 528 | 529 | def test_can_create_model_list_field_with_instances(self): 530 | class TestModel(FileMakerModel): 531 | f = fields.CharField() 532 | f = fields.ModelListField(model=TestModel) 533 | f.value = [TestModel(f='abc')] 534 | self.assertEqual(f.value, [TestModel(f='abc')]) 535 | 536 | def test_gtin_field(self): 537 | test_values = ( 538 | ('0', False), 539 | ('01', False), 540 | ('012', False), 541 | ('0123', False), 542 | ('01234', False), 543 | ('012345', True), 544 | ('01a345', False), 545 | ('0123456', True), 546 | ('0123z56', False), 547 | ('01234567', True), 548 | ('01234b67', False), 549 | ('012345678', False), 550 | ('0123456789', True), 551 | ('0c23456789', False), 552 | ('01234567890', False), 553 | ('012345678901', True), 554 | ('01234567d901', False), 555 | ('0123456789012', True), 556 | ('012e456789012', False), 557 | ('01234567890123', True), 558 | ('012e4567890123', False), 559 | ('012345678901234', True), 560 | ('012e45678901234', False), 561 | ('0123456789012345', True), 562 | ('012e456789012345', False), 563 | ) 564 | f = fields.GTINField() 565 | for val, validate in test_values: 566 | if validate: 567 | f.value = val 568 | self.assertEqual(f.value, val) 569 | else: 570 | with self.assertRaises(FileMakerValidationError): 571 | f.value = val 572 | 573 | @patch('filemaker.fields.FileField._get_file') 574 | def test_image_field(self, get): 575 | get.return_value.content_type = 'text/plain' 576 | with self.assertRaises(FileMakerValidationError): 577 | fields.ImageField()._get_file('') 578 | get.return_value.content_type = 'image/jpg' 579 | self.assertEqual(fields.ImageField()._get_file(''), get.return_value) 580 | 581 | def test_file_field_init(self): 582 | field = fields.FileField() 583 | self.assertTrue(isinstance(field.base_url, urlobject.URLObject)) 584 | self.assertEqual(text_type(field.base_url), '') 585 | field = fields.FileField(base_url='http://google.com/') 586 | self.assertTrue(isinstance(field.base_url, urlobject.URLObject)) 587 | self.assertEqual(text_type(field.base_url), 'http://google.com/') 588 | 589 | @httprettified 590 | def test_file_field_get_http(self): 591 | url = 'http://example.com/logo.jpg' 592 | HTTPretty.register_uri( 593 | HTTPretty.GET, 594 | url, 595 | responses=[ 596 | HTTPretty.Response(body='', status=404), 597 | HTTPretty.Response( 598 | body='monkeys', status=200, 599 | content_type='text/html; charset=utf-8'), 600 | HTTPretty.Response( 601 | body='I am totally an image', status=200, 602 | content_type='image/jpg'), 603 | ] 604 | ) 605 | url = urlobject.URLObject(url) 606 | field = fields.FileField() 607 | # Error response 608 | self.assertEqual(field._get_http(url), (None, None)) 609 | # Content-Type with charset 610 | self.assertEqual(field._get_http(url), (b'monkeys', 'text/html')) 611 | # Normal 612 | self.assertEqual( 613 | field._get_http(url), (b'I am totally an image', 'image/jpg')) 614 | 615 | def test_file_field_get_file_invalid_scheme(self): 616 | field = fields.FileField() 617 | with self.assertRaises(FileMakerValidationError): 618 | url = urlobject.URLObject( 619 | 'sftp://user:pass@domain.com/path/to/file.jpg') 620 | field._get_file(url) 621 | 622 | @httprettified 623 | def test_file_field_get_file_download_failure(self): 624 | url = 'http://example.com/image.jpg' 625 | HTTPretty.register_uri(HTTPretty.GET, url, body='', status=404) 626 | field = fields.FileField() 627 | with self.assertRaises(FileMakerValidationError): 628 | url = urlobject.URLObject(url) 629 | field._get_file(url) 630 | 631 | @httprettified 632 | def test_file_field_get_file_normalises_jpg_type(self): 633 | field = fields.FileField() 634 | url = urlobject.URLObject('http://example.com/image.jpg') 635 | for mime in ('image/jpeg', 'image/jpe', 'image/jpeg'): 636 | HTTPretty.register_uri( 637 | HTTPretty.GET, text_type(url), body='', status=200, 638 | content_type=mime) 639 | uploaded = field._get_file(url) 640 | self.assertEqual(uploaded['content-type'], 'image/jpg') 641 | self.assertTrue(uploaded['filename'].endswith('.jpg')) 642 | 643 | @httprettified 644 | def test_file_field_get_file_success(self): 645 | field = fields.FileField() 646 | url = urlobject.URLObject('http://example.com/file.txt') 647 | HTTPretty.register_uri( 648 | HTTPretty.GET, text_type(url), body='I am text', status=200, 649 | content_type='text/plain') 650 | uploaded = field._get_file(url) 651 | self.assertEqual(uploaded['content-type'], 'text/plain') 652 | self.assertEqual(uploaded['content'], b'I am text') 653 | 654 | def test_file_field_coerce(self): 655 | field = fields.FileField() 656 | with patch.object(field, '_get_file') as get: 657 | get.side_effect = lambda url: url 658 | # Add scheme, auth, and domain if missing 659 | field.base_url = urlobject.URLObject('http://user:pass@domain.com') 660 | self.assertEqual( 661 | text_type(field.coerce('/image.jpg')), 662 | 'http://user:pass@domain.com/image.jpg' 663 | ) 664 | # Otherwise leave as is... 665 | self.assertEqual( 666 | field.coerce('http://username:password@example.com/test.jpg'), 667 | 'http://username:password@example.com/test.jpg', 668 | ) 669 | 670 | @httprettified 671 | def test_file_field_to_django(self): 672 | field = fields.FileField() 673 | url = urlobject.URLObject('http://example.com/file.txt') 674 | HTTPretty.register_uri( 675 | HTTPretty.GET, text_type(url), body='I am text', status=200, 676 | content_type='text/plain') 677 | field.value = 'http://example.com/file.txt' 678 | djuploaded = field.to_django() 679 | self.assertEqual(djuploaded.content_type, 'text/plain') 680 | self.assertEqual(djuploaded.read(), b'I am text') 681 | 682 | @httprettified 683 | def test_file_field_to_filemaker(self): 684 | storage = Mock() 685 | field = fields.FileField(null=True, storage=storage) 686 | self.assertEqual(field.to_filemaker(), '') 687 | url = urlobject.URLObject('http://example.com/file.txt') 688 | HTTPretty.register_uri( 689 | HTTPretty.GET, text_type(url), body='I am text', status=200, 690 | content_type='text/plain') 691 | field.value = 'http://example.com/file.txt' 692 | storage.url.return_value = '/some/url' 693 | self.assertEqual(field.to_filemaker(), '/some/url') 694 | 695 | def test_file_field_no_value(self): 696 | field = fields.FileField(null=True) 697 | self.assertEqual(None, field.to_django()) 698 | 699 | def test_uploaded_file_field_to_django(self): 700 | f = Mock() 701 | f.name = 'some_name.txt' 702 | storage = Mock() 703 | storage.open.return_value = f 704 | field = fields.UploadedFileField(storage=storage) 705 | field.value = 'some_name.txt' 706 | self.assertEqual(field.to_django(), f) 707 | field = fields.UploadedFileField(null=True) 708 | field.value = None 709 | self.assertEqual(field.to_django(), None) 710 | 711 | def test_uploaded_file_field_to_filemaker(self): 712 | f = Mock() 713 | f.name = 'some_name.txt' 714 | storage = Mock() 715 | storage.open.return_value = f 716 | field = fields.UploadedFileField(null=True, storage=storage) 717 | field.value = None 718 | self.assertEqual(field.to_filemaker(), '') 719 | field.value = 'some_name.txt' 720 | storage.url.return_value = '/media/some_file.txt' 721 | self.assertEqual(field.to_filemaker(), '/media/some_file.txt') 722 | 723 | def test_uploaded_file_field_coerce(self): 724 | f = Mock() 725 | f.name = 'some_name.txt' 726 | storage = Mock() 727 | storage.open.return_value = f 728 | field = fields.UploadedFileField(storage=storage) 729 | self.assertEqual( 730 | field.coerce('some_name.txt'), 731 | {'file': f, 'filename': f.name}, 732 | ) 733 | storage.open.side_effect = Exception 734 | with self.assertRaises(FileMakerValidationError): 735 | field.coerce('some_name.txt') 736 | 737 | 738 | @override_settings(INSTALLED_APPS=['django.contrib.sites', 739 | 'django.contrib.flatpages']) 740 | class TestFilemakerBase(TransactionTestCase): 741 | 742 | def test_meta_assignment(self): 743 | 744 | class TestMetaModel(FileMakerModel): 745 | 746 | meta = { 747 | 'django_pk_name': 'id', 748 | 'something': 'else', 749 | } 750 | 751 | instance = TestMetaModel() 752 | self.assertFalse(hasattr(instance, 'meta')) 753 | self.assertTrue(hasattr(instance, '_meta')) 754 | fields = [ 755 | 'connection', 756 | 'pk_name', 757 | 'django_pk_name', 758 | 'django_model', 759 | 'django_field_map', 760 | 'abstract', 761 | 'something', 762 | ] 763 | for field in fields: 764 | self.assertIn(field, instance._meta) 765 | self.assertEqual(instance._meta['django_pk_name'], 'id') 766 | 767 | def test_meta_assignment_no_dict(self): 768 | 769 | class TestMetaModel(FileMakerModel): 770 | meta = 'pk' 771 | 772 | instance = TestMetaModel() 773 | self.assertFalse(hasattr(instance, 'meta')) 774 | self.assertTrue(hasattr(instance, '_meta')) 775 | 776 | def test_fields_assignment(self): 777 | 778 | class TestFieldsModel(FileMakerModel): 779 | name = fields.CharField('name') 780 | value = fields.IntegerField('value') 781 | not_a_field = 10 782 | 783 | instance = TestFieldsModel() 784 | self.assertTrue(hasattr(instance, '_fields')) 785 | self.assertIn('name', instance._fields) 786 | self.assertIn('value', instance._fields) 787 | self.assertNotIn('not_a_field', instance._fields) 788 | for val in instance._fields.values(): 789 | self.assertTrue(isinstance(val, fields.BaseFileMakerField)) 790 | instance.name = 'key' 791 | self.assertEqual(instance.name, 'key') 792 | self.assertEqual(instance._fields['name'].value, 'key') 793 | with self.assertRaises(FileMakerValidationError): 794 | instance.value = 'a' 795 | self.assertEqual(instance.not_a_field, 10) 796 | instance.not_a_field = 20 797 | self.assertEqual(instance.not_a_field, 20) 798 | 799 | def test_init_with_obj(self): 800 | 801 | class TestInitModel(FileMakerModel): 802 | name = fields.CharField('name') 803 | value = fields.IntegerField('value') 804 | 805 | fm_obj = Mock() 806 | fm_obj.name = 'Test' 807 | fm_obj.value = 42 808 | instance = TestInitModel(fm_obj) 809 | self.assertEqual(instance.name, 'Test') 810 | self.assertEqual(instance.value, 42) 811 | self.assertEqual(instance._fm_obj, fm_obj) 812 | 813 | def test_simple_model_to_django(self): 814 | 815 | mock_fm_site = Mock() 816 | mock_fm_site.name = 'Test' 817 | mock_fm_site.domain = 'test.tld' 818 | mock_fm_site.id = 3 819 | 820 | class TestFMSite(FileMakerModel): 821 | id = fields.IntegerField('id') 822 | name = fields.CharField('name') 823 | domain = fields.CharField('domain') 824 | 825 | meta = {'model': Site} 826 | 827 | instance = TestFMSite(mock_fm_site) 828 | site = instance.to_django() 829 | self.assertTrue(Site.objects 830 | .filter(name='Test', domain='test.tld', pk=3).exists()) 831 | self.assertTrue(isinstance(site, Site)) 832 | site.delete() 833 | site = instance.to_django() 834 | self.assertTrue(isinstance(site, Site)) 835 | self.assertTrue( 836 | Site.objects.filter(name='Test', domain='test.tld', pk=3).exists()) 837 | 838 | def test_model_to_django_existing(self): 839 | 840 | Site.objects.create(pk=3, name='Change Me', domain='wrong.tld') 841 | mock_fm_site = Mock() 842 | mock_fm_site.name = 'Test' 843 | mock_fm_site.domain = 'test.tld' 844 | mock_fm_site.id = 3 845 | 846 | class TestFMSite(FileMakerModel): 847 | id = fields.IntegerField('id') 848 | name = fields.CharField('name') 849 | domain = fields.CharField('domain') 850 | 851 | meta = {'model': Site} 852 | 853 | instance = TestFMSite(mock_fm_site) 854 | site = instance.to_django() 855 | self.assertTrue(Site.objects 856 | .filter(name='Test', domain='test.tld', pk=3).exists()) 857 | self.assertTrue(isinstance(site, Site)) 858 | 859 | def test_model_to_django_meta_abstract(self): 860 | mock_fm_site = Mock() 861 | mock_fm_site.name = 'Test' 862 | mock_fm_site.domain = 'test.tld' 863 | mock_fm_site.id = 3 864 | 865 | class TestFMSite(FileMakerModel): 866 | pk = fields.IntegerField('id') 867 | name = fields.CharField('name') 868 | domain = fields.CharField('domain') 869 | 870 | meta = {'model': Site, 'abstract': True} 871 | 872 | instance = TestFMSite(mock_fm_site) 873 | site = instance.to_django() 874 | self.assertTrue(isinstance(site, Site)) 875 | self.assertTrue( 876 | Site.objects.filter(name='Test', domain='test.tld', pk=3).exists()) 877 | 878 | def test_model_to_django_meta_model(self): 879 | mock_fm_site = Mock() 880 | mock_fm_site.name = 'Test' 881 | mock_fm_site.domain = 'test.tld' 882 | mock_fm_site.id = 3 883 | 884 | class TestFMSite(FileMakerModel): 885 | pk = fields.IntegerField('id') 886 | name = fields.CharField('name') 887 | domain = fields.CharField('domain') 888 | 889 | meta = {} 890 | instance = TestFMSite(mock_fm_site) 891 | self.assertEqual(instance.to_django(), None) 892 | 893 | def test_model_to_django_pk_name(self): 894 | mock_fm_site = Mock() 895 | mock_fm_site.name = 'Test' 896 | mock_fm_site.domain = 'test.tld' 897 | mock_fm_site.id = 3 898 | 899 | class TestFMSite(FileMakerModel): 900 | name = fields.CharField('name') 901 | domain = fields.CharField('domain') 902 | 903 | meta = { 904 | 'model': Site, 905 | 'pk_name': None, 906 | } 907 | instance = TestFMSite(mock_fm_site) 908 | site = instance.to_django() 909 | self.assertNotEqual(site.pk, None) 910 | 911 | def test_model_to_django_meta_django_field_map(self): 912 | mock_fm_site = Mock() 913 | mock_fm_site.name = 'Test' 914 | mock_fm_site.domain = 'test.tld' 915 | mock_fm_site.id = None 916 | 917 | class TestFMSite(FileMakerModel): 918 | id = fields.IntegerField('id', null=True) 919 | name = fields.CharField('name') 920 | domain = fields.CharField('domain') 921 | 922 | meta = { 923 | 'model': Site, 924 | 'django_field_map': ( 925 | ('name', 'domain'), 926 | ('domain', 'name'), 927 | ), 928 | } 929 | instance = TestFMSite(mock_fm_site) 930 | site = instance.to_django() 931 | self.assertEqual(site.domain, 'Test') 932 | self.assertEqual(site.name, 'test.tld') 933 | self.assertNotEqual(site.pk, None) 934 | 935 | def test_to_one_relations(self): 936 | 937 | mock_fm_site = Mock() 938 | mock_fm_site.name = 'Test' 939 | mock_fm_site.domain = 'test.tld' 940 | mock_fm_site.id = 3 941 | 942 | mock_fm_redirect = Mock() 943 | mock_fm_redirect.site = mock_fm_site 944 | mock_fm_redirect.old_path = '/old-path/' 945 | mock_fm_redirect.new_path = '/new-path/' 946 | 947 | class TestFMSite(FileMakerModel): 948 | id = fields.IntegerField('id', null=True) 949 | name = fields.CharField('name') 950 | domain = fields.CharField('domain') 951 | 952 | meta = { 953 | 'model': Site, 954 | 'abstract': True, 955 | } 956 | 957 | class TestFMRedirect(FileMakerModel): 958 | old_path = fields.CharField('old_path') 959 | new_path = fields.CharField('new_path') 960 | site = fields.ModelField('site', model=TestFMSite) 961 | 962 | meta = { 963 | 'model': Redirect, 964 | } 965 | 966 | instance = TestFMRedirect(mock_fm_redirect) 967 | instance.to_django(save=True) 968 | self.assertTrue( 969 | Redirect.objects.filter(site__id=3, site__name='Test', 970 | site__domain='test.tld', 971 | old_path='/old-path/', 972 | new_path='/new-path/').exists() 973 | ) 974 | 975 | def test_to_many_relations(self): 976 | from django.contrib.flatpages.models import FlatPage 977 | # syncdb in case flatpages isn't installed 978 | call_command('syncdb', interactive=False) 979 | mock_fm_site = NonCallableMock() 980 | mock_fm_site.name = 'Test' 981 | mock_fm_site.domain = 'test.tld' 982 | mock_fm_site.id = 3 983 | mock_fm_site_2 = NonCallableMock() 984 | mock_fm_site_2.name = 'Test2' 985 | mock_fm_site_2.domain = 'test2.tld' 986 | mock_fm_site_2.id = 4 987 | 988 | mock_fm_flatpage = NonCallableMagicMock() 989 | mock_fm_flatpage.sites = [mock_fm_site, mock_fm_site_2] 990 | mock_fm_flatpage.content = 'Content' 991 | mock_fm_flatpage.title = 'Title' 992 | mock_fm_flatpage.url = '/url/' 993 | 994 | class TestFMSite(FileMakerModel): 995 | id = fields.IntegerField('id', null=True) 996 | name = fields.CharField('name') 997 | domain = fields.CharField('domain') 998 | 999 | meta = { 1000 | 'model': Site, 1001 | 'abstract': True, 1002 | } 1003 | 1004 | class TestFMFlatPage(FileMakerModel): 1005 | content = fields.CharField('content') 1006 | title = fields.CharField('title') 1007 | url = fields.CharField('url') 1008 | sites = fields.ModelListField('sites', model=TestFMSite) 1009 | 1010 | meta = { 1011 | 'model': FlatPage, 1012 | } 1013 | 1014 | instance = TestFMFlatPage(mock_fm_flatpage) 1015 | instance.to_django(save=True) 1016 | sites = Site.objects.filter(pk__in=[3, 4]) 1017 | self.assertEqual(sites.count(), 2) 1018 | self.assertTrue( 1019 | FlatPage.objects.filter(sites__in=sites, content='Content', 1020 | title='Title', url='/url/').exists() 1021 | ) 1022 | FlatPage.objects.all().delete() 1023 | instance._meta['to_many_action'] = '' 1024 | instance.to_django(save=True) 1025 | sites = Site.objects.filter(pk__in=[3, 4]) 1026 | self.assertEqual(sites.count(), 2) 1027 | self.assertTrue( 1028 | FlatPage.objects.filter(sites__in=sites, content='Content', 1029 | title='Title', url='/url/').exists() 1030 | ) 1031 | FlatPage.objects.all().delete() 1032 | 1033 | def test_to_dict(self): 1034 | 1035 | class DictTestToOneModel(FileMakerModel): 1036 | to_one_name = fields.CharField('to_one_name') 1037 | 1038 | class DictTestToManyModel(FileMakerModel): 1039 | to_many_name = fields.CharField() 1040 | 1041 | class DictTestModel(FileMakerModel): 1042 | name = fields.CharField() 1043 | value = fields.IntegerField() 1044 | to_one = fields.ModelField(model=DictTestToOneModel) 1045 | to_many = fields.ModelListField(model=DictTestToManyModel) 1046 | 1047 | t_one = NonCallableMock() 1048 | t_one.to_one_name = 'one' 1049 | t_many = NonCallableMock() 1050 | t_many.to_many_name = 'many' 1051 | m = Mock() 1052 | m.name = 'Name' 1053 | m.value = 1 1054 | m.to_one = t_one 1055 | m.to_many = [t_many] 1056 | instance = DictTestModel(m) 1057 | d = instance.to_dict() 1058 | self.assertDictEqual( 1059 | d, 1060 | { 1061 | 'name': 'Name', 1062 | 'value': 1, 1063 | 'to_one': {'to_one_name': 'one'}, 1064 | 'to_many': [{'to_many_name': 'many'}], 1065 | } 1066 | ) 1067 | 1068 | def test_fm_attr_initialisation(self): 1069 | 1070 | class TestModel(FileMakerModel): 1071 | name = fields.CharField() 1072 | value = fields.IntegerField('a_field') 1073 | 1074 | instance = TestModel() 1075 | self.assertEqual(instance._fields['name'].fm_attr, 'name') 1076 | self.assertEqual(instance._fields['value'].fm_attr, 'a_field') 1077 | 1078 | def test_independence(self): 1079 | 1080 | class TestModel(FileMakerModel): 1081 | name = fields.CharField() 1082 | value = fields.IntegerField() 1083 | 1084 | t1 = TestModel() 1085 | t2 = TestModel() 1086 | t1.name = 'Name' 1087 | t2.name = 'Name2' 1088 | t1.value = '1' 1089 | t2.value = '2' 1090 | permutations = \ 1091 | itertools.permutations([t1.name, t2.name, t1.value, t2.value], 2) 1092 | for f1, f2 in permutations: 1093 | self.assertNotEqual(f1, f2) 1094 | 1095 | def test_ordering_different_models(self): 1096 | 1097 | class TestModel(FileMakerModel): 1098 | name = fields.CharField() 1099 | value = fields.IntegerField() 1100 | 1101 | class TestModel2(FileMakerModel): 1102 | name2 = fields.CharField() 1103 | value2 = fields.IntegerField() 1104 | 1105 | with self.assertRaises(TypeError): 1106 | TestModel() < TestModel2() 1107 | 1108 | def test_ordering_no_meta_ordering(self): 1109 | 1110 | class TestModel(FileMakerModel): 1111 | name = fields.CharField() 1112 | value = fields.IntegerField() 1113 | 1114 | class TestIDModel(FileMakerModel): 1115 | id = fields.IntegerField() 1116 | name = fields.CharField() 1117 | value = fields.IntegerField() 1118 | 1119 | with self.assertRaises(ValueError): 1120 | TestModel() < TestModel() 1121 | 1122 | t1 = TestIDModel(id=1) 1123 | t2 = TestIDModel(id=2) 1124 | self.assertTrue(t1 < t2) 1125 | 1126 | def test_ordering_meta(self): 1127 | 1128 | class TestModel(FileMakerModel): 1129 | name = fields.CharField() 1130 | value = fields.IntegerField() 1131 | 1132 | meta = {'ordering': 'name'} 1133 | 1134 | class TestReverseModel(FileMakerModel): 1135 | name = fields.CharField() 1136 | value = fields.IntegerField() 1137 | 1138 | meta = {'ordering': '-name'} 1139 | 1140 | t1 = TestModel(name='a') 1141 | t2 = TestModel(name='b') 1142 | self.assertTrue(t1 < t2) 1143 | tr1 = TestReverseModel(name='a') 1144 | tr2 = TestReverseModel(name='b') 1145 | self.assertTrue(tr1 > tr2) 1146 | 1147 | def test_deep_getattr(self): 1148 | obj = Mock() 1149 | obj.exodus = 'metal' 1150 | obj.narwhal.bacon = 'midnight' 1151 | obj.warning = 1 1152 | with self.assertRaises(ValueError): 1153 | deep_getattr(obj, None) 1154 | with self.assertRaises(ValueError): 1155 | deep_getattr(obj, '') 1156 | self.assertEqual(obj, deep_getattr(obj, '+self')) 1157 | self.assertEqual('metal', deep_getattr(obj, 'exodus')) 1158 | self.assertEqual('midnight', deep_getattr(obj, 'narwhal.bacon')) 1159 | self.assertEqual(None, deep_getattr(obj, 'warning.monkeyspunk')) 1160 | 1161 | 1162 | class TestRawManager(TransactionTestCase): 1163 | 1164 | def setUp(self): 1165 | connection = { 1166 | 'db': 'db_name', 1167 | 'url': 'http://user:pass@domain.com/', 1168 | 'layout': 'layout_name', 1169 | 'response_layout': 'response_layout_name', 1170 | } 1171 | self.manager = RawManager(**connection) 1172 | 1173 | def test_init(self): 1174 | connection = { 1175 | 'db': 'db_name', 1176 | 'url': 'http://user:pass@domain.com/', 1177 | 'layout': 'layout_name', 1178 | 'response_layout': 'response_layout_name', 1179 | } 1180 | manager = RawManager(**connection) 1181 | self.assertEqual(manager.url, 'http://domain.com/') 1182 | self.assertEqual(manager.auth, ('user', 'pass')) 1183 | 1184 | def test_set_script(self): 1185 | mgr = self.manager.set_script('some_script') 1186 | self.assertEqual(mgr.params['-script'], 'some_script') 1187 | mgr = self.manager.set_script('some_script', 'prefind') 1188 | self.assertEqual(mgr.params['-script.prefind'], 'some_script') 1189 | 1190 | def test_set_record_id(self): 1191 | mgr = self.manager.set_record_id(1) 1192 | self.assertEqual(mgr.params['-recid'], 1) 1193 | 1194 | def test_set_modifier_id(self): 1195 | mgr = self.manager.set_modifier_id(1) 1196 | self.assertEqual(mgr.params['-modid'], 1) 1197 | 1198 | def test_set_logical_operator(self): 1199 | mgr = self.manager.set_logical_operator('and') 1200 | self.assertEqual(mgr.params['-lop'], 'and') 1201 | 1202 | def test_set_group_size(self): 1203 | mgr = self.manager.set_group_size(5) 1204 | self.assertEqual(mgr.params['-max'], 5) 1205 | 1206 | def test_skip_records(self): 1207 | mgr = self.manager.set_skip_records(5) 1208 | self.assertEqual(mgr.params['-skip'], 5) 1209 | 1210 | def test_add_db_param(self): 1211 | mgr = self.manager.add_db_param('foo', 'bar') 1212 | self.assertEqual(mgr.params['foo'], 'bar') 1213 | mgr = self.manager.add_db_param('foo', 'bar', 'neq') 1214 | self.assertEqual(mgr.params['foo'], 'bar') 1215 | self.assertEqual(mgr.params['foo.op'], 'neq') 1216 | 1217 | def test_add_sort_param(self): 1218 | mgr = self.manager.add_sort_param('foo') 1219 | self.assertEqual(mgr.params['-sortfield.0'], 'foo') 1220 | self.assertEqual(mgr.params['-sortorder.0'], 'ascend') 1221 | 1222 | 1223 | class TestFileMakerSubModel(FileMakerModel): 1224 | 1225 | index = fields.IntegerField('Sub_Index') 1226 | 1227 | 1228 | class TestFileMakerSubModelOnSelf(FileMakerModel): 1229 | 1230 | pub_date = fields.DateField('Publication') 1231 | 1232 | 1233 | class TestFileMakerMainModel(FileMakerModel): 1234 | 1235 | text = fields.CharField('Item_Text') 1236 | subs = fields.ModelListField('SUB_ITEMS', model=TestFileMakerSubModel) 1237 | pubs = fields.ModelField('+self', model=TestFileMakerSubModelOnSelf) 1238 | 1239 | meta = { 1240 | 'connection': { 1241 | 'db': 'db_name', 1242 | 'url': 'http://user:pass@domain.com/', 1243 | 'layout': 'layout_name', 1244 | }, 1245 | } 1246 | 1247 | 1248 | class TestManager(TransactionTestCase): 1249 | 1250 | def setUp(self): 1251 | connection = { 1252 | 'db': 'db_name', 1253 | 'url': 'http://user:pass@domain.com/', 1254 | 'layout': 'layout_name', 1255 | 'response_layout': 'response_layout_name', 1256 | } 1257 | self.cls = MagicMock() 1258 | self.cls._meta = {'connection': connection} 1259 | self.cls.DoesNotExist = Exception 1260 | self.manager = Manager(self.cls) 1261 | 1262 | def test_len(self): 1263 | fm_data = MagicMock(resultset=[1, 2, 3]) 1264 | self.manager._fm_data = fm_data 1265 | self.assertEqual(len(self.manager), 3) 1266 | self.assertEqual(self.manager.count(), 3) 1267 | 1268 | def test_invalid_get_item_or_slice(self): 1269 | with self.assertRaises(TypeError): 1270 | self.manager['a'] 1271 | with self.assertRaises(AssertionError): 1272 | self.manager[-1] 1273 | 1274 | def test_get_item(self): 1275 | fm_data = MagicMock(resultset=[1, 2, 3]) 1276 | self.cls.side_effect = ['first', 'second', 'third'] 1277 | self.manager._fm_data = fm_data 1278 | self.assertEqual('first', self.manager[0]) 1279 | 1280 | def test_slice_a(self): 1281 | fm_data = MagicMock(resultset=[1, 2, 3]) 1282 | self.cls.side_effect = ['first', 'second', 'third'] 1283 | self.manager._fm_data = fm_data 1284 | self.assertEqual(['first', 'second'], self.manager[0:2]) 1285 | self.assertEqual(self.manager.params['-max'], 2) 1286 | 1287 | def test_slice_b(self): 1288 | fm_data = MagicMock(resultset=[1, 2, 3]) 1289 | self.cls.side_effect = ['first', 'second', 'third'] 1290 | self.manager._fm_data = fm_data 1291 | self.assertEqual(['second', 'third'], self.manager[1:3]) 1292 | self.assertEqual(self.manager.params['-max'], 2) 1293 | self.assertEqual(self.manager.params['-skip'], 1) 1294 | 1295 | def test_resolve_fm_field(self): 1296 | mgr = TestFileMakerMainModel.objects 1297 | self.assertEqual( 1298 | mgr._resolve_fm_field('text'), 1299 | 'Item_Text' 1300 | ) 1301 | self.assertEqual( 1302 | mgr._resolve_fm_field('subs__index'), 1303 | 'SUB_ITEMS::Sub_Index' 1304 | ) 1305 | self.assertEqual( 1306 | mgr._resolve_fm_field('pubs__pub_date'), 1307 | 'Publication' 1308 | ) 1309 | with self.assertRaises(ValueError): 1310 | mgr._resolve_fm_field('i-m__not__a__field__i-m__a__free__man') 1311 | with self.assertRaises(ValueError): 1312 | mgr._resolve_fm_field('menofield') 1313 | with self.assertRaises(ValueError): 1314 | mgr._resolve_fm_field('subs__busted') 1315 | 1316 | def test_preprocess_resultset(self): 1317 | # This should be a no-op but may as well test to make sure 1318 | self.assertEqual(123, self.manager.preprocess_resultset(123)) 1319 | 1320 | def test_all(self): 1321 | # This should just return a clone of the manager 1322 | new_mgr = self.manager.all() 1323 | self.assertEqual(new_mgr.__dict__, self.manager.__dict__) 1324 | 1325 | def test_filter_basic(self): 1326 | mgr = TestFileMakerMainModel.objects.filter(text='crazy text') 1327 | self.assertEqual( 1328 | dict(mgr.params), 1329 | dict(QueryDict('Item_Text.op=eq&-max=50&Item_Text=crazy%20text')) 1330 | ) 1331 | 1332 | def test_filter_with_op(self): 1333 | mgr = TestFileMakerMainModel.objects.filter( 1334 | pubs__pub_date__lt=datetime.date(1999, 12, 31)) 1335 | self.assertEqual( 1336 | dict(QueryDict(mgr.params.urlencode())), 1337 | dict(QueryDict('-max=50&Publication=1999-12-31&Publication.op=lt')) 1338 | ) 1339 | 1340 | def test_filter_failure(self): 1341 | with self.assertRaises(ValueError): 1342 | TestFileMakerMainModel.objects.filter(whatwhat=123) 1343 | with self.assertRaises(ValueError): 1344 | TestFileMakerMainModel.objects.filter(pubs__pub_date__noop=123) 1345 | 1346 | def test_manager_not_available_from_instance(self): 1347 | fmm = TestFileMakerMainModel() 1348 | with self.assertRaises(AttributeError): 1349 | fmm.objects 1350 | 1351 | def test_get(self): 1352 | with patch.object(self.manager, 'filter') as fltr: 1353 | fltr.return_value = [1, 2, 3] 1354 | self.assertEqual(self.manager.get(pk=123), 1) 1355 | fltr.assert_called_with(pk=123) 1356 | fltr.reset_mock() 1357 | fltr.return_value = [] 1358 | with self.assertRaises(self.cls.DoesNotExist): 1359 | self.manager.get(pk=123) 1360 | 1361 | def test_order_by(self): 1362 | mgr = TestFileMakerMainModel.objects.order_by('text') 1363 | self.assertEqual( 1364 | dict(mgr.params), 1365 | dict(QueryDict( 1366 | '-max=50&-sortorder.0=ascend&-sortfield.0=Item_Text')) 1367 | ) 1368 | mgr = mgr.order_by('pubs__pub_date', '-text') 1369 | self.assertEqual( 1370 | dict(mgr.params), 1371 | dict(QueryDict( 1372 | '-sortfield.1=Item_Text&-max=50&-sortorder.0=ascend&' 1373 | '-sortorder.1=descend&-sortfield.0=Publication')) 1374 | ) 1375 | mgr = mgr.order_by('-pubs__pub_date') 1376 | self.assertEqual( 1377 | dict(mgr.params), 1378 | dict(QueryDict( 1379 | '-max=50&-sortorder.0=descend&-sortfield.0=Publication')) 1380 | ) 1381 | 1382 | 1383 | class TestUtils(TransactionTestCase): 1384 | 1385 | @override_settings(FILEMAKER_DJANGO_FIELD_MAP={ 1386 | CharField: fields.CharField, 1387 | IntegerField: 'filemaker.fields.IntegerField', 1388 | }) 1389 | def test_get_field_class(self): 1390 | self.assertEqual(get_field_class(CharField), fields.CharField) 1391 | self.assertEqual(get_field_class(CharField()), fields.CharField) 1392 | self.assertEqual( 1393 | get_field_class('django.db.models.fields.IntegerField'), 1394 | fields.IntegerField 1395 | ) 1396 | 1397 | 1398 | @skipUnless( 1399 | os.path.exists(os.path.join( 1400 | os.path.dirname(__file__), 1401 | '../test_xml/test_xml.xml' 1402 | )), 1403 | 'Test XML file test_xml.xml not found.' 1404 | ) 1405 | class TestParser(TransactionTestCase): 1406 | 1407 | def setUp(self): 1408 | with open(os.path.join( 1409 | os.path.dirname(__file__), '../test_xml/test_xml.xml')) as f: 1410 | self.xml = force_bytes(f.read()) 1411 | self.fm_object = FMXMLObject(self.xml) 1412 | 1413 | def test_parser_data(self): 1414 | self.assertEqual(self.fm_object.data, self.xml) 1415 | 1416 | def test_parser_database(self): 1417 | self.assertEqual( 1418 | self.fm_object.database, 1419 | { 1420 | 'database': 'art', 1421 | 'date-format': 'MM/dd/yyyy', 1422 | 'layout': 'web3', 1423 | 'table': 'art', 1424 | 'time-format': 'HH:mm:ss', 1425 | 'timestamp-format': 'MM/dd/yyyy HH:mm:ss', 1426 | 'total-count': '12', 1427 | } 1428 | ) 1429 | 1430 | def test_parser_errorcode(self): 1431 | self.assertEqual(self.fm_object.errorcode, 0) 1432 | 1433 | def test_parser_field_names(self): 1434 | self.assertEqual( 1435 | self.fm_object.field_names, 1436 | ['Title', 'Artist', 'Style', 'length'] 1437 | ) 1438 | 1439 | def test_parser_metadata(self): 1440 | self.assertEqual( 1441 | self.fm_object.metadata, 1442 | { 1443 | 'Artist': { 1444 | 'auto-enter': 'no', 1445 | 'four-digit-year': 'no', 1446 | 'global': 'no', 1447 | 'max-repeat': '1', 1448 | 'name': 'Artist', 1449 | 'not-empty': 'no', 1450 | 'numeric-only': 'no', 1451 | 'result': 'text', 1452 | 'time-of-day': 'no', 1453 | 'type': 'normal' 1454 | }, 1455 | 'Style': { 1456 | 'auto-enter': 'no', 1457 | 'four-digit-year': 'no', 1458 | 'global': 'no', 1459 | 'max-repeat': '1', 1460 | 'name': 'Style', 1461 | 'not-empty': 'no', 1462 | 'numeric-only': 'no', 1463 | 'result': 'text', 1464 | 'time-of-day': 'no', 1465 | 'type': 'normal' 1466 | }, 1467 | 'Title': { 1468 | 'auto-enter': 'no', 1469 | 'four-digit-year': 'no', 1470 | 'global': 'no', 1471 | 'max-repeat': '1', 1472 | 'name': 'Title', 1473 | 'not-empty': 'no', 1474 | 'numeric-only': 'no', 1475 | 'result': 'text', 1476 | 'time-of-day': 'no', 1477 | 'type': 'normal' 1478 | }, 1479 | 'length': { 1480 | 'auto-enter': 'no', 1481 | 'four-digit-year': 'no', 1482 | 'global': 'no', 1483 | 'max-repeat': '1', 1484 | 'name': 'length', 1485 | 'not-empty': 'no', 1486 | 'numeric-only': 'no', 1487 | 'result': 'number', 1488 | 'time-of-day': 'no', 1489 | 'type': 'calculation' 1490 | } 1491 | } 1492 | ) 1493 | 1494 | def test_parser_product(self): 1495 | self.assertEqual( 1496 | self.fm_object.product, 1497 | { 1498 | 'build': '12/31/2012', 1499 | 'name': 'FileMaker Web Publishing Engine', 1500 | 'version': '0.0.0.0', 1501 | } 1502 | ) 1503 | 1504 | def test_parser_resultset(self): 1505 | self.assertEqual( 1506 | self.fm_object.resultset, 1507 | [ 1508 | { 1509 | 'Artist': 'Claude Monet', 1510 | 'MODID': 6, 1511 | 'RECORDID': 14, 1512 | 'Style': '', 1513 | 'Title': 'Spring in Giverny 3', 1514 | 'length': '19' 1515 | } 1516 | ] 1517 | ) 1518 | 1519 | def test_resultset_len(self): 1520 | self.assertEqual(len(self.fm_object), 1) 1521 | 1522 | def test_resultset_getitem(self): 1523 | self.assertEqual(self.fm_object[0], self.fm_object.resultset[0]) 1524 | 1525 | def test_fm_document_get_attrs(self): 1526 | self.assertEqual( 1527 | self.fm_object.resultset[0].length, 1528 | self.fm_object.resultset[0]['length'] 1529 | ) 1530 | 1531 | def test_fm_document_set_attrs(self): 1532 | doc = FMDocument(a=1, b=2) 1533 | doc.a = 3 1534 | self.assertEqual(doc['a'], 3) 1535 | self.assertEqual(doc.a, 3) 1536 | doc['a'] = 4 1537 | self.assertEqual(doc['a'], 4) 1538 | self.assertEqual(doc.a, 4) 1539 | 1540 | @skipUnless( 1541 | os.path.exists(os.path.join( 1542 | os.path.dirname(__file__), 1543 | '../test_xml/test_error_xml.xml') 1544 | ), 1545 | 'Test XML file test_error_xml.xml not found.' 1546 | ) 1547 | def test_errorcode(self): 1548 | with open(os.path.join( 1549 | os.path.dirname(__file__), 1550 | '../test_xml/test_error_xml.xml')) as f: 1551 | xml = force_bytes(f.read()) 1552 | with self.assertRaises(FileMakerServerError): 1553 | FMXMLObject(xml) 1554 | 1555 | @skipUnless( 1556 | os.path.exists(os.path.join( 1557 | os.path.dirname(__file__), 1558 | '../test_xml/test_broken_xml.xml') 1559 | ), 1560 | 'Test XML file test_broken_xml.xml not found.' 1561 | ) 1562 | def test_broken_xml(self): 1563 | with open(os.path.join( 1564 | os.path.dirname(__file__), 1565 | '../test_xml/test_broken_xml.xml')) as f: 1566 | xml = force_bytes(f.read()) 1567 | with self.assertRaises(FileMakerServerError): 1568 | FMXMLObject(xml) 1569 | 1570 | @skipUnless( 1571 | os.path.exists(os.path.join( 1572 | os.path.dirname(__file__), 1573 | '../test_xml/test_invalid_xml.xml') 1574 | ), 1575 | 'Test XML file test_invalid_xml.xml not found.' 1576 | ) 1577 | def test_invalid_xml(self): 1578 | with open(os.path.join( 1579 | os.path.dirname(__file__), 1580 | '../test_xml/test_invalid_xml.xml')) as f: 1581 | xml = force_bytes(f.read()) 1582 | with self.assertRaises(FileMakerServerError): 1583 | FMXMLObject(xml) 1584 | 1585 | @skipUnless( 1586 | os.path.exists(os.path.join( 1587 | os.path.dirname(__file__), 1588 | '../test_xml/test_empty_xml.xml') 1589 | ), 1590 | 'Test XML file test_empty_xml.xml not found.' 1591 | ) 1592 | def test_empty_xml(self): 1593 | with open(os.path.join( 1594 | os.path.dirname(__file__), 1595 | '../test_xml/test_empty_xml.xml')) as f: 1596 | xml = force_bytes(f.read()) 1597 | fm_object = FMXMLObject(xml) 1598 | self.assertEqual(fm_object.resultset, []) 1599 | self.assertEqual(len(fm_object), 0) 1600 | --------------------------------------------------------------------------------