commit 4c6730a146ba82fc6ea007009467f22c459046c8 Author: Benjamin Dauvergne Date: Thu Sep 22 11:19:36 2016 +0200 import release 0.9.19 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d869f75 --- /dev/null +++ b/LICENSE @@ -0,0 +1,23 @@ +Copyright (c) 2012, Matthew Schinckel. +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * The names of its contributors may not be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL MATTHEW SCHINCKEL BE LIABLE FOR ANY DIRECT, +INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, +BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE +OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..f62f0df --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,6 @@ +recursive-include jsonfield *.py +include jsonfield/VERSION +include README.rst +include tests.py +include LICENSE +recursive-exclude jsonfield *.pyc \ No newline at end of file diff --git a/PKG-INFO b/PKG-INFO new file mode 100644 index 0000000..a7a706d --- /dev/null +++ b/PKG-INFO @@ -0,0 +1,197 @@ +Metadata-Version: 1.1 +Name: django-jsonfield +Version: 0.9.19 +Summary: JSONField for django models +Home-page: http://bitbucket.org/schinckel/django-jsonfield/ +Author: Matthew Schinckel +Author-email: matt@schinckel.net +License: UNKNOWN +Description: django-jsonfield + =================== + + .. image:: https://codeship.com/projects/2e1a3d30-7db7-0132-629f-4abd151a3721/status?branch=default + + I had a serious need for a JSON field for django. There were a couple out + there, but none packaged up nicely on bitbucket/github that were usable + with ``pip install -e``. + + So I took the code from `David Cramer's blog`_, and packaged it up. + + Usage + ----- + + To use, just install the package, and then use the field:: + + from django.db import models + import jsonfield + + class MyModel(models.Model): + the_json = jsonfield.JSONField() + + Now, it will validate the JSON on entry, and store it as a string in the + database. When you instantiate/fetch the object, it will be turned back + into a python list/dict/string. + + There is also a ``TypedJSONField``, that allows you to define data types that must be included within each object in the array. More documentation to follow. + + + Notes + ~~~~~ + + If no ``default`` is provided, and ``null=True`` is not passed in to the + field constructor, then a default of ``{}`` will be used. + + There are also a couple of other bits and bobs: + + Extras + ------ + + jsonify templatetag + ~~~~~~~~~~~~~~~~~~~ + This allows you to convert a python data structure into JSON within a template:: + + {% load jsonify %} + + + + History + ---------- + + 0.9.19 + ~~~~~~ + Allow passing `decoder_kwargs` as an argument to a field. This dict will be passed as kwargs to + the `json.loads()` calls when loading data that is a string. + + You may also set this as a global value in settings.JSONFIELD_DECODER_KWARGS. + + A new dict is created for each field: so if this value is altered after field definition, it shouldn't + affect already attached fields. + + 0.9.16 + ~~~~~~ + Allow passing an argument of `encoder_class` to a field, which will result in that object (or + the object located at that path, for instance `core.utils.JSONEncoder`) being used as the `cls` + argument when serializing objects. + + You may also set this as a global value in settings.JSONFIELD_ENCODER_CLASS + + 0.9.15 + ~~~~~~ + Bump version number to get around uploading issues. + + 0.9.14 + ~~~~~~ + No longer hit the db to work out db_type. + + 0.9.12 + ~~~~~~ + Cache the result of db_type. + Handle incoming data from multiple select widget better. + + 0.9.9 + ~~~~~ + Finally strip out non-required files. + + 0.9.8 + ~~~~~ + Remove freezegun workarounds. + Fix broken build. + + 0.9.4 + ~~~~~ + Fixes for mutable defaults: we serialize and then deserialize in this + case, so you can still use ``default={}``. + + 0.9.3 + ~~~~~ + Remove support for storing data using Postgres' 9.2's JSON data type, as + you cannot currently query against this! + + Remove support for django < 1.3. + + + 0.9.0 + ~~~~~ + Add LICENSE file. + Added TypedJSONField. + + + 0.8.10 + ~~~~~~ + Allow ``{{ variable|jsonify }}`` to work with querysets. + + 0.8.8 + ~~~~~ + Prevent circular import problem with django 1.3.1 and gargoyle. + + 0.8.7 + ~~~~~ + Better handle null=True and blank=True: it should make sense what they do now. + + 0.8.5 + ~~~~~ + Allow for '{}' and '[]', and make them not appear to be None. + + 0.8.4 + ~~~~~ + Ensure the version number file is installed with the package. + + 0.8.3 + ~~~~~ + Store the version number in one place only, now. + + 0.8.2 + ~~~~~ + Oops. Packaging error prevented install from pypi. Added README.rst to manifest. + + 0.8.1 + ~~~~~ + Converting to string does nothing, as serializing a model instance with a JSONField would have a string version of that field, instead of it embedded inline. (Back to pre 0.8 behaviour). + + Added better querying support: (``field__contains={'key':'value','key2':'value2'}`` works.) + + Removed JSONTableWidget from package. + + 0.8 + ~~~ + (Many thanks to `IanLewis`_ for these features) + + Supports django 1.2 + + Supports callable and json serializable objects as default + + Implemented get_db_prep_value() + + Add tests and test runner. + + Removed JSONTableWidget from README. + + 0.7.1 + ~~~~~ + Don't fail when trying to install before django is installed. + + 0.7 + ~~~ + First time I tagged releases. + + + Todo + ---------- + Allow for passing in a function to use for processing unknown data types. + + Convert date/time objects nicely to/from ISO strings (YYYY-mm-dd HH:MM:SS + TZNAME). This is actually a bit tricky, as we don't know if we are expecting + a date/time object. We may parse objects as we go, but there could be + some performance issues with this. I'm tempted to say "only do this on TypedJSONField()" + + .. _David Cramer's blog: http://justcramer.com/2009/04/14/cleaning-up-with-json-and-sql/ + .. _IanLewis: https://bitbucket.org/IanLewis + +Platform: UNKNOWN +Classifier: Programming Language :: Python +Classifier: Programming Language :: Python :: 3 +Classifier: License :: OSI Approved :: BSD License +Classifier: Operating System :: OS Independent +Classifier: Framework :: Django diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..35d4fcb --- /dev/null +++ b/README.rst @@ -0,0 +1,182 @@ +django-jsonfield +=================== + +.. image:: https://codeship.com/projects/2e1a3d30-7db7-0132-629f-4abd151a3721/status?branch=default + +I had a serious need for a JSON field for django. There were a couple out +there, but none packaged up nicely on bitbucket/github that were usable +with ``pip install -e``. + +So I took the code from `David Cramer's blog`_, and packaged it up. + +Usage +----- + +To use, just install the package, and then use the field:: + + from django.db import models + import jsonfield + + class MyModel(models.Model): + the_json = jsonfield.JSONField() + +Now, it will validate the JSON on entry, and store it as a string in the +database. When you instantiate/fetch the object, it will be turned back +into a python list/dict/string. + +There is also a ``TypedJSONField``, that allows you to define data types that must be included within each object in the array. More documentation to follow. + + +Notes +~~~~~ + +If no ``default`` is provided, and ``null=True`` is not passed in to the +field constructor, then a default of ``{}`` will be used. + +There are also a couple of other bits and bobs: + +Extras +------ + +jsonify templatetag +~~~~~~~~~~~~~~~~~~~ +This allows you to convert a python data structure into JSON within a template:: + + {% load jsonify %} + + + +History +---------- + +0.9.19 +~~~~~~ +Allow passing `decoder_kwargs` as an argument to a field. This dict will be passed as kwargs to +the `json.loads()` calls when loading data that is a string. + +You may also set this as a global value in settings.JSONFIELD_DECODER_KWARGS. + +A new dict is created for each field: so if this value is altered after field definition, it shouldn't +affect already attached fields. + +0.9.16 +~~~~~~ +Allow passing an argument of `encoder_class` to a field, which will result in that object (or +the object located at that path, for instance `core.utils.JSONEncoder`) being used as the `cls` +argument when serializing objects. + +You may also set this as a global value in settings.JSONFIELD_ENCODER_CLASS + +0.9.15 +~~~~~~ +Bump version number to get around uploading issues. + +0.9.14 +~~~~~~ +No longer hit the db to work out db_type. + +0.9.12 +~~~~~~ +Cache the result of db_type. +Handle incoming data from multiple select widget better. + +0.9.9 +~~~~~ +Finally strip out non-required files. + +0.9.8 +~~~~~ +Remove freezegun workarounds. +Fix broken build. + +0.9.4 +~~~~~ +Fixes for mutable defaults: we serialize and then deserialize in this +case, so you can still use ``default={}``. + +0.9.3 +~~~~~ +Remove support for storing data using Postgres' 9.2's JSON data type, as +you cannot currently query against this! + +Remove support for django < 1.3. + + +0.9.0 +~~~~~ +Add LICENSE file. +Added TypedJSONField. + + +0.8.10 +~~~~~~ +Allow ``{{ variable|jsonify }}`` to work with querysets. + +0.8.8 +~~~~~ +Prevent circular import problem with django 1.3.1 and gargoyle. + +0.8.7 +~~~~~ +Better handle null=True and blank=True: it should make sense what they do now. + +0.8.5 +~~~~~ +Allow for '{}' and '[]', and make them not appear to be None. + +0.8.4 +~~~~~ +Ensure the version number file is installed with the package. + +0.8.3 +~~~~~ +Store the version number in one place only, now. + +0.8.2 +~~~~~ +Oops. Packaging error prevented install from pypi. Added README.rst to manifest. + +0.8.1 +~~~~~ +Converting to string does nothing, as serializing a model instance with a JSONField would have a string version of that field, instead of it embedded inline. (Back to pre 0.8 behaviour). + +Added better querying support: (``field__contains={'key':'value','key2':'value2'}`` works.) + +Removed JSONTableWidget from package. + +0.8 +~~~ +(Many thanks to `IanLewis`_ for these features) + +Supports django 1.2 + +Supports callable and json serializable objects as default + +Implemented get_db_prep_value() + +Add tests and test runner. + +Removed JSONTableWidget from README. + +0.7.1 +~~~~~ +Don't fail when trying to install before django is installed. + +0.7 +~~~ +First time I tagged releases. + + +Todo +---------- +Allow for passing in a function to use for processing unknown data types. + +Convert date/time objects nicely to/from ISO strings (YYYY-mm-dd HH:MM:SS +TZNAME). This is actually a bit tricky, as we don't know if we are expecting +a date/time object. We may parse objects as we go, but there could be +some performance issues with this. I'm tempted to say "only do this on TypedJSONField()" + +.. _David Cramer's blog: http://justcramer.com/2009/04/14/cleaning-up-with-json-and-sql/ +.. _IanLewis: https://bitbucket.org/IanLewis diff --git a/jsonfield/__init__.py b/jsonfield/__init__.py new file mode 100644 index 0000000..10b91dc --- /dev/null +++ b/jsonfield/__init__.py @@ -0,0 +1,7 @@ +import os +__version__ = open(os.path.join(os.path.dirname(__file__), 'VERSION')).read().strip() + +try: + from .fields import JSONField # NOQA +except ImportError: + pass diff --git a/jsonfield/fields.py b/jsonfield/fields.py new file mode 100644 index 0000000..fa78689 --- /dev/null +++ b/jsonfield/fields.py @@ -0,0 +1,181 @@ +from __future__ import unicode_literals +import json + +from django.core.exceptions import ValidationError +from django.conf import settings +from django.db import models, DatabaseError, transaction +from django.utils.translation import ugettext_lazy as _ +from django.utils import six +from django.core.cache import cache + +from decimal import Decimal +import datetime + +from .utils import default, _resolve_object_path +from .widgets import JSONWidget +from .forms import JSONFormField +from jsonfield import __version__ + +DB_TYPE_CACHE_KEY = ( + 'django-jsonfield:db-type:%s' % __version__ + + '%(ENGINE)s:%(HOST)s:%(PORT)s:%(NAME)s' +) + + +class JSONField(six.with_metaclass(models.SubfieldBase, models.Field)): + """ + A field that will ensure the data entered into it is valid JSON. + """ + default_error_messages = { + 'invalid': _("'%s' is not a valid JSON string.") + } + description = "JSON object" + + def __init__(self, *args, **kwargs): + if not kwargs.get('null', False): + kwargs['default'] = kwargs.get('default', dict) + self.encoder_kwargs = { + 'indent': kwargs.pop('indent', getattr(settings, 'JSONFIELD_INDENT', None)), + } + # This can be an object (probably a class), or a path which can be imported, resulting + # in an object. + encoder_class = kwargs.pop('encoder_class', getattr(settings, 'JSONFIELD_ENCODER_CLASS', None)) + if encoder_class: + self.encoder_kwargs['cls'] = _resolve_object_path(encoder_class) + + self.decoder_kwargs = dict(kwargs.pop('decoder_kwargs', getattr(settings, 'JSONFIELD_DECODER_KWARGS', {}))) + super(JSONField, self).__init__(*args, **kwargs) + self.validate(self.get_default(), None) + + def formfield(self, **kwargs): + defaults = { + 'form_class': JSONFormField, + 'widget': JSONWidget + } + defaults.update(**kwargs) + return super(JSONField, self).formfield(**defaults) + + def validate(self, value, model_instance): + if not self.null and value is None: + raise ValidationError(self.error_messages['null']) + try: + self.get_prep_value(value) + except ValueError: + raise ValidationError(self.error_messages['invalid'] % value) + + def get_default(self): + if self.has_default(): + default = self.default + if callable(default): + default = default() + if isinstance(default, six.string_types): + return json.loads(default, **self.decoder_kwargs) + return json.loads(json.dumps(default, **self.encoder_kwargs), **self.decoder_kwargs) + return super(JSONField, self).get_default() + + def get_internal_type(self): + return 'TextField' + + def db_type(self, connection): + if connection.vendor == 'postgresql': + # Only do jsonb if in pg 9.4+ + if connection.pg_version >= 90400: + return 'jsonb' + return 'text' + if connection.vendor == 'mysql': + return 'longtext' + if connection.vendor == 'oracle': + return 'long' + return 'text' + + def to_python(self, value): + if isinstance(value, six.string_types): + if value == "": + if self.null: + return None + if self.blank: + return "" + try: + value = json.loads(value, **self.decoder_kwargs) + except ValueError: + msg = self.error_messages['invalid'] % value + raise ValidationError(msg) + # TODO: Look for date/time/datetime objects within the structure? + return value + + def get_db_prep_value(self, value, connection=None, prepared=None): + return self.get_prep_value(value) + + def get_prep_value(self, value): + if value is None: + if not self.null and self.blank: + return "" + return None + return json.dumps(value, **self.encoder_kwargs) + + def get_prep_lookup(self, lookup_type, value): + if lookup_type in ["exact", "iexact"]: + return self.to_python(self.get_prep_value(value)) + if lookup_type == "in": + return [self.to_python(self.get_prep_value(v)) for v in value] + if lookup_type == "isnull": + return value + if lookup_type in ["contains", "icontains"]: + if isinstance(value, (list, tuple)): + raise TypeError("Lookup type %r not supported with argument of %s" % ( + lookup_type, type(value).__name__ + )) + # Need a way co combine the values with '%', but don't escape that. + return self.get_prep_value(value)[1:-1].replace(', ', r'%') + if isinstance(value, dict): + return self.get_prep_value(value)[1:-1] + return self.to_python(self.get_prep_value(value)) + raise TypeError('Lookup type %r not supported' % lookup_type) + + def value_to_string(self, obj): + return self._get_val_from_obj(obj) + + +class TypedJSONField(JSONField): + """ + + """ + def __init__(self, *args, **kwargs): + self.json_required_fields = kwargs.pop('required_fields', {}) + self.json_validators = kwargs.pop('validators', []) + + super(TypedJSONField, self).__init__(*args, **kwargs) + + def cast_required_fields(self, obj): + if not obj: + return + for field_name, field_type in self.json_required_fields.items(): + obj[field_name] = field_type.to_python(obj[field_name]) + + def to_python(self, value): + value = super(TypedJSONField, self).to_python(value) + + if isinstance(value, list): + for item in value: + self.cast_required_fields(item) + else: + self.cast_required_fields(value) + + return value + + def validate(self, value, model_instance): + super(TypedJSONField, self).validate(value, model_instance) + + for v in self.json_validators: + if isinstance(value, list): + for item in value: + v(item) + else: + v(value) + +try: + from south.modelsinspector import add_introspection_rules + add_introspection_rules([], ['^jsonfield\.fields\.JSONField']) + add_introspection_rules([], ['^jsonfield\.fields\.TypedJSONField']) +except ImportError: + pass diff --git a/jsonfield/forms.py b/jsonfield/forms.py new file mode 100644 index 0000000..68ebcea --- /dev/null +++ b/jsonfield/forms.py @@ -0,0 +1,31 @@ +import json + +from django import forms +from django.utils import six + +from .widgets import JSONWidget + + +class JSONFormField(forms.CharField): + empty_values = [None, ''] + + def __init__(self, *args, **kwargs): + if 'widget' not in kwargs: + kwargs['widget'] = JSONWidget + super(JSONFormField, self).__init__(*args, **kwargs) + + def to_python(self, value): + if isinstance(value, six.string_types) and value: + try: + return json.loads(value) + except ValueError as exc: + raise forms.ValidationError( + 'JSON decode error: %s' % (six.u(exc.args[0]),) + ) + else: + return value + + def validate(self, value): + # This is required in older django versions. + if value in self.empty_values and self.required: + raise forms.ValidationError(self.error_messages['required'], code='required') diff --git a/jsonfield/models.py b/jsonfield/models.py new file mode 100644 index 0000000..e69de29 diff --git a/jsonfield/templatetags/__init__.py b/jsonfield/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jsonfield/templatetags/jsonify.py b/jsonfield/templatetags/jsonify.py new file mode 100644 index 0000000..8d94a6d --- /dev/null +++ b/jsonfield/templatetags/jsonify.py @@ -0,0 +1,15 @@ +import json + +from django import template +from django.utils.safestring import mark_safe +from jsonfield.utils import TZAwareJSONEncoder + +register = template.Library() + + +@register.filter +def jsonify(value): + # If we have a queryset, then convert it into a list. + if getattr(value, 'all', False): + value = list(value) + return mark_safe(json.dumps(value, cls=TZAwareJSONEncoder)) diff --git a/jsonfield/tests/__init__.py b/jsonfield/tests/__init__.py new file mode 100644 index 0000000..c8f487c --- /dev/null +++ b/jsonfield/tests/__init__.py @@ -0,0 +1,2 @@ +from .test_fields import * # NOQA +from .test_forms import * # NOQA diff --git a/jsonfield/tests/jsonfield_test_app/__init__.py b/jsonfield/tests/jsonfield_test_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jsonfield/tests/jsonfield_test_app/forms.py b/jsonfield/tests/jsonfield_test_app/forms.py new file mode 100644 index 0000000..bd8d5a0 --- /dev/null +++ b/jsonfield/tests/jsonfield_test_app/forms.py @@ -0,0 +1,16 @@ +from django import forms + +from jsonfield.forms import JSONFormField +from .models import JSONFieldTestModel + + +class JSONTestForm(forms.Form): + json_data = JSONFormField() + optional_json_data = JSONFormField(required=False) + + +class JSONTestModelForm(forms.ModelForm): + + class Meta: + model = JSONFieldTestModel + exclude = [] diff --git a/jsonfield/tests/jsonfield_test_app/models.py b/jsonfield/tests/jsonfield_test_app/models.py new file mode 100644 index 0000000..eb3781e --- /dev/null +++ b/jsonfield/tests/jsonfield_test_app/models.py @@ -0,0 +1,31 @@ +from django.db import models +from jsonfield.fields import JSONField + + +class JSONFieldTestModel(models.Model): + json = JSONField("test", null=True, blank=True) + + class Meta: + app_label = 'jsonfield' + + +class JSONFieldWithDefaultTestModel(models.Model): + json = JSONField(default={"sukasuka": "YAAAAAZ"}) + + class Meta: + app_label = 'jsonfield' + + +class BlankJSONFieldTestModel(models.Model): + null_json = JSONField(null=True) + blank_json = JSONField(blank=True) + + class Meta: + app_label = 'jsonfield' + + +class CallableDefaultModel(models.Model): + json = JSONField(default=lambda: {'x': 2}) + + class Meta: + app_label = 'jsonfield' diff --git a/jsonfield/tests/test_fields.py b/jsonfield/tests/test_fields.py new file mode 100644 index 0000000..ec779a7 --- /dev/null +++ b/jsonfield/tests/test_fields.py @@ -0,0 +1,165 @@ +#:coding=utf-8: +import unittest + +from django.test import TestCase as DjangoTestCase +from django.utils.encoding import force_text +from django import forms + +from jsonfield.tests.jsonfield_test_app.models import * # NOQA +from jsonfield.fields import JSONField + + +class JSONFieldTest(DjangoTestCase): + def test_json_field(self): + obj = JSONFieldTestModel(json='''{ + "spam": "eggs" + }''') + self.assertEqual(obj.json, {'spam': 'eggs'}) + + def test_json_field_empty(self): + obj = JSONFieldTestModel(json='') + self.assertEqual(obj.json, None) + + def test_json_field_save(self): + JSONFieldTestModel.objects.create( + id=10, + json='''{ + "spam": "eggs" + }''', + ) + obj2 = JSONFieldTestModel.objects.get(id=10) + self.assertEqual(obj2.json, {'spam': 'eggs'}) + + def test_json_field_save_empty(self): + JSONFieldTestModel.objects.create(id=10, json='') + obj2 = JSONFieldTestModel.objects.get(id=10) + self.assertEqual(obj2.json, None) + + def test_db_prep_save(self): + field = JSONField("test") + field.set_attributes_from_name("json") + self.assertEqual(None, field.get_db_prep_save(None, connection=None)) + self.assertEqual('{"spam": "eggs"}', field.get_db_prep_save({"spam": "eggs"}, connection=None)) + + def test_formfield(self): + from jsonfield.forms import JSONFormField + from jsonfield.widgets import JSONWidget + field = JSONField("test") + field.set_attributes_from_name("json") + formfield = field.formfield() + self.assertEqual(type(formfield), JSONFormField) + self.assertEqual(type(formfield.widget), JSONWidget) + + def test_formfield_clean_blank(self): + field = JSONField("test") + formfield = field.formfield() + self.assertRaisesMessage(forms.ValidationError, force_text(formfield.error_messages['required']), formfield.clean, value='') + + def test_formfield_clean_none(self): + field = JSONField("test") + formfield = field.formfield() + self.assertRaisesMessage(forms.ValidationError, force_text(formfield.error_messages['required']), formfield.clean, value=None) + + def test_formfield_null_and_blank_clean_blank(self): + field = JSONField("test", null=True, blank=True) + formfield = field.formfield() + self.assertEqual(formfield.clean(value=''), '') + + def test_formfield_null_and_blank_clean_none(self): + field = JSONField("test", null=True, blank=True) + formfield = field.formfield() + self.assertEqual(formfield.clean(value=None), None) + + def test_formfield_blank_clean_blank(self): + field = JSONField("test", null=False, blank=True) + formfield = field.formfield() + self.assertEqual(formfield.clean(value=''), '') + + def test_formfield_blank_clean_none(self): + # Hmm, I'm not sure how to do this. What happens if we pass a + # None to a field that has null=False? + field = JSONField("test", null=False, blank=True) + formfield = field.formfield() + self.assertEqual(formfield.clean(value=None), None) + + def test_default_value(self): + obj = JSONFieldWithDefaultTestModel.objects.create() + obj = JSONFieldWithDefaultTestModel.objects.get(id=obj.id) + self.assertEqual(obj.json, {'sukasuka': 'YAAAAAZ'}) + + def test_query_object(self): + JSONFieldTestModel.objects.create(json={}) + JSONFieldTestModel.objects.create(json={'foo': 'bar'}) + self.assertEqual(2, JSONFieldTestModel.objects.all().count()) + self.assertEqual(1, JSONFieldTestModel.objects.exclude(json={}).count()) + self.assertEqual(1, JSONFieldTestModel.objects.filter(json={}).count()) + self.assertEqual(1, JSONFieldTestModel.objects.filter(json={'foo': 'bar'}).count()) + self.assertEqual(1, JSONFieldTestModel.objects.filter(json__contains={'foo': 'bar'}).count()) + JSONFieldTestModel.objects.create(json={'foo': 'bar', 'baz': 'bing'}) + self.assertEqual(2, JSONFieldTestModel.objects.filter(json__contains={'foo': 'bar'}).count()) + # This next one is a bit hard to do without proper lookups, which I'm unlikely to implement. + # self.assertEqual(1, JSONFieldTestModel.objects.filter(json__contains={'baz':'bing', 'foo':'bar'}).count()) + self.assertEqual(2, JSONFieldTestModel.objects.filter(json__contains='foo').count()) + # This code needs to be implemented! + self.assertRaises(TypeError, lambda: JSONFieldTestModel.objects.filter(json__contains=['baz', 'foo'])) + + def test_query_isnull(self): + JSONFieldTestModel.objects.create(json=None) + JSONFieldTestModel.objects.create(json={}) + JSONFieldTestModel.objects.create(json={'foo': 'bar'}) + + self.assertEqual(1, JSONFieldTestModel.objects.filter(json=None).count()) + self.assertEqual(None, JSONFieldTestModel.objects.get(json=None).json) + + def test_jsonfield_blank(self): + BlankJSONFieldTestModel.objects.create(blank_json='', null_json=None) + obj = BlankJSONFieldTestModel.objects.get() + self.assertEqual(None, obj.null_json) + self.assertEqual("", obj.blank_json) + obj.save() + obj = BlankJSONFieldTestModel.objects.get() + self.assertEqual(None, obj.null_json) + self.assertEqual("", obj.blank_json) + + def test_callable_default(self): + CallableDefaultModel.objects.create() + obj = CallableDefaultModel.objects.get() + self.assertEqual({'x': 2}, obj.json) + + def test_callable_default_overridden(self): + CallableDefaultModel.objects.create(json={'x': 3}) + obj = CallableDefaultModel.objects.get() + self.assertEqual({'x': 3}, obj.json) + + def test_mutable_default_checking(self): + obj1 = JSONFieldWithDefaultTestModel() + obj2 = JSONFieldWithDefaultTestModel() + + obj1.json['foo'] = 'bar' + self.assertNotIn('foo', obj2.json) + + def test_invalid_json(self): + obj = JSONFieldTestModel() + obj.json = '{"foo": 2}' + self.assertIn('foo', obj.json) + with self.assertRaises(forms.ValidationError): + obj.json = '{"foo"}' + + def test_invalid_json_default(self): + with self.assertRaises(ValueError): + JSONField('test', default='{"foo"}') + + def test_indent(self): + JSONField('test', indent=2) + + @unittest.expectedFailure + def test_string_is_valid_json(self): + JSONFieldTestModel.objects.create(json='"foo"') + self.assertEqual('foo', JSONFieldTestModel.objects.get().json) + + +class SavingModelsTest(DjangoTestCase): + def test_saving_null(self): + obj = BlankJSONFieldTestModel.objects.create(blank_json='', null_json=None) + self.assertEqual('', obj.blank_json) + self.assertEqual(None, obj.null_json) diff --git a/jsonfield/tests/test_forms.py b/jsonfield/tests/test_forms.py new file mode 100644 index 0000000..6f51e74 --- /dev/null +++ b/jsonfield/tests/test_forms.py @@ -0,0 +1,62 @@ +from django.test import TestCase as DjangoTestCase +from django.forms import ValidationError + +from jsonfield.forms import JSONFormField +from jsonfield.tests.jsonfield_test_app.forms import JSONTestForm + + +class JSONFormFieldTest(DjangoTestCase): + def test_form_field_clean_empty_object(self): + field = JSONFormField(required=False) + self.assertEqual({}, field.clean('{}')) + + def test_form_field_clean_object(self): + field = JSONFormField(required=False) + self.assertEqual( + {'foo': 'bar', 'baz': 2}, + field.clean('{"foo":"bar","baz":2}') + ) + + def test_form_field_widget(self): + field = JSONFormField(required=False) + self.assertIn( + '{\n "a": true\n}', + field.widget.render('json', {"a": True}) + ) + + def test_form_field_clean_empty_array(self): + field = JSONFormField(required=False) + self.assertEqual([], field.clean('[]')) + + def test_required_form_field_array(self): + field = JSONFormField(required=True) + self.assertEqual([], field.clean('[]')) + + def test_required_form_field_object(self): + field = JSONFormField(required=True) + self.assertEqual({}, field.clean('{}')) + + def test_required_form_field_empty(self): + field = JSONFormField(required=True) + with self.assertRaises(ValidationError): + field.clean('') + + def test_invalid_json(self): + field = JSONFormField(required=True) + + with self.assertRaises(ValidationError): + field.clean('{"foo"}') + + +class JSONFormTest(DjangoTestCase): + def test_form_clean(self): + form = JSONTestForm({}) + self.assertFalse(form.is_valid()) + + +class JSONFormMultipleSelectFieldTest(DjangoTestCase): + def test_multiple_select_data(self): + form = JSONTestForm({'json_data': ['SA', 'WA']}) + assert form.is_valid() + + self.assertEqual(['SA', 'WA'], form.cleaned_data['json_data']) diff --git a/jsonfield/utils.py b/jsonfield/utils.py new file mode 100644 index 0000000..fce90b2 --- /dev/null +++ b/jsonfield/utils.py @@ -0,0 +1,44 @@ +import datetime +from decimal import Decimal + +from django.core.serializers.json import DjangoJSONEncoder + +import six + +class TZAwareJSONEncoder(DjangoJSONEncoder): + def default(self, obj): + if isinstance(obj, datetime.datetime): + return obj.strftime("%Y-%m-%d %H:%M:%S%z") + return super(TZAwareJSONEncoder, self).default(obj) + + +def default(o): + if hasattr(o, 'to_json'): + return o.to_json() + if isinstance(o, Decimal): + return str(o) + if isinstance(o, datetime.datetime): + if o.tzinfo: + return o.strftime('%Y-%m-%dT%H:%M:%S%z') + return o.strftime("%Y-%m-%dT%H:%M:%S") + if isinstance(o, datetime.date): + return o.strftime("%Y-%m-%d") + if isinstance(o, datetime.time): + if o.tzinfo: + return o.strftime('%H:%M:%S%z') + return o.strftime("%H:%M:%S") + if isinstance(o, set): + return list(o) + + raise TypeError(repr(o) + " is not JSON serializable") + + +def _resolve_object_path(dotted_name): + if isinstance(dotted_name, six.string_types): + path = dotted_name.split('.') + module = __import__(dotted_name.rsplit('.', 1)[0]) + for item in path[1:-1]: + module = getattr(module, item) + return getattr(module, path[-1]) + + return dotted_name diff --git a/jsonfield/widgets.py b/jsonfield/widgets.py new file mode 100644 index 0000000..71fe446 --- /dev/null +++ b/jsonfield/widgets.py @@ -0,0 +1,19 @@ +import json + +from django import forms +from django.utils import six + +from .utils import default + + +class JSONWidget(forms.Textarea): + def render(self, name, value, attrs=None): + if value is None: + value = "" + if not isinstance(value, six.string_types): + value = json.dumps(value, indent=2, default=default) + return super(JSONWidget, self).render(name, value, attrs) + + +class JSONSelectWidget(forms.SelectMultiple): + pass diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..861a9f5 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,5 @@ +[egg_info] +tag_build = +tag_date = 0 +tag_svn_revision = 0 + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..e1ccdee --- /dev/null +++ b/setup.py @@ -0,0 +1,24 @@ +import os +from setuptools import setup + +setup( + name = "django-jsonfield", + version = open(os.path.join(os.path.dirname(__file__), 'jsonfield', 'VERSION')).read().strip(), + description = "JSONField for django models", + long_description = open("README.rst").read(), + url = "http://bitbucket.org/schinckel/django-jsonfield/", + author = "Matthew Schinckel", + author_email = "matt@schinckel.net", + packages = [ + "jsonfield", + ], + classifiers = [ + 'Programming Language :: Python', + 'Programming Language :: Python :: 3', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Framework :: Django', + ], + test_suite='tests.main', + include_package_data=True, +) diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..b5d9b16 --- /dev/null +++ b/tests.py @@ -0,0 +1,49 @@ +import os +import sys +import django + +BASE_PATH = os.path.dirname(__file__) + + +def main(): + """ + Standalone django model test with a 'memory-only-django-installation'. + You can play with a django model without a complete django app installation. + http://www.djangosnippets.org/snippets/1044/ + """ + os.environ["DJANGO_SETTINGS_MODULE"] = "django.conf.global_settings" + from django.conf import global_settings + + global_settings.INSTALLED_APPS = ( + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'jsonfield', + ) + global_settings.DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.{DB_ENGINE}'.format(**os.environ), + 'NAME': 'jsonfield-{DB_NAME}'.format(**os.environ), + 'USER': os.environ.get('DB_USER', ''), + 'PASSWORD': os.environ.get('DB_PASSWORD', ''), + } + } + + global_settings.STATIC_URL = "/static/" + global_settings.MEDIA_ROOT = os.path.join(BASE_PATH, 'static') + global_settings.STATIC_ROOT = global_settings.MEDIA_ROOT + + global_settings.SECRET_KEY = '334ebe58-a77d-4321-9d01-a7d2cb8d3eea' + from django.test.utils import get_runner + test_runner = get_runner(global_settings) + + test_runner = test_runner() + + if getattr(django, 'setup', None): + django.setup() + + failures = test_runner.run_tests(['jsonfield']) + + sys.exit(failures) + +if __name__ == '__main__': + main()