├── .coveragerc ├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── README.md ├── janyson ├── __init__.py ├── apps.py ├── decorators.py ├── descriptors.py ├── fields.py ├── forms.py ├── locale │ ├── en │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── ru │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── options.py ├── utils.py └── validators.py ├── requirements.txt ├── requirements_dev.txt ├── runtests.py ├── runtests.sh ├── setup.py ├── tests ├── __init__.py ├── models.py ├── test_settings.py ├── tests_forms.py ├── tests_main.py ├── tests_options.py └── tests_validators.py └── tox.ini /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = janyson 3 | omit = 4 | janyson/apps.py 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | .coverage 4 | htmlcov/ 5 | 6 | .tox/ 7 | 8 | README.rst 9 | dist/ 10 | django_janyson.egg-info/ 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 2.7 4 | - 3.4 5 | - 3.5 6 | services: 7 | - postgresql 8 | addons: 9 | postgresql: "9.4" 10 | install: 11 | - pip install -r requirements.txt 12 | - pip install -r requirements_dev.txt 13 | - pip install coveralls 14 | script: 15 | - coverage run runtests.py -U postgres -w 16 | after_success: 17 | coveralls 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016, un.def 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright 10 | notice, this list of conditions and the following disclaimer in the 11 | documentation and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND ANY 14 | EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE FOR ANY 17 | DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT 21 | LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY 22 | OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH 23 | DAMAGE. 24 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include requirements.txt 4 | recursive-include janyson/locale * 5 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Django JanySON 2 | ============== 3 | 4 | [![Build Status](https://travis-ci.org/un-def/django-janyson.svg?branch=master)](https://travis-ci.org/un-def/django-janyson) 5 | [![Coverage Status](https://coveralls.io/repos/github/un-def/django-janyson/badge.svg?branch=master)](https://coveralls.io/github/un-def/django-janyson?branch=master) 6 | [![PyPI version](https://badge.fury.io/py/django-janyson.svg)](https://pypi.python.org/pypi/django-janyson/) 7 | [![PyPI license](https://img.shields.io/pypi/l/django-janyson.svg?maxAge=3600)](https://raw.githubusercontent.com/un-def/django-janyson/master/LICENSE) 8 | 9 | Store additional model fields as JSON object in PostgreSQL's `jsonb` field and work with them as regular model fields. Need new boolean/text/foreign key/many-to-many/etc. field? Just add the decorator with the field description to your model. It's all! No more annoying migrations. 10 | 11 | 12 | ### Installation 13 | 14 | * Install the package using `pip install django-janyson`. 15 | * Add `janyson` to `INSTALLED_APPS` setting (optional). 16 | 17 | 18 | ### Requirements 19 | 20 | * Python 2.7+ or 3.4+ 21 | * Django 1.9+ with psycopg2 22 | * [six](https://pypi.python.org/pypi/six) 23 | 24 | 25 | ### Example 26 | 27 | ```python 28 | from django.db import models 29 | 30 | from janyson.decorators import add_fields 31 | from janyson.fields import JanySONField 32 | 33 | 34 | class Tag(models.Model): 35 | 36 | name = models.CharField(max_length=16) 37 | 38 | def __str__(self): 39 | return "[Tag: {}]".format(self.name) 40 | 41 | 42 | extra_fields = { 43 | 'desc': {'type': 'str'}, 44 | 'qty': {'type': 'num', 'default': 0, 'use_default': True}, 45 | 'avail': {'type': 'nullbool', 'use_default': True}, 46 | 'main_tag': {'type': 'fk', 'model': Tag}, 47 | 'tags': {'type': 'm2m', 'model': 'demo_app.Tag'}, 48 | } 49 | 50 | common_options = { 51 | 'use_default': False, 52 | 'dir_hide': True, 53 | } 54 | 55 | @add_fields(extra_fields, field_options=common_options, janyson_field='extra') 56 | class Item(models.Model): 57 | 58 | name = models.CharField(max_length=64) 59 | extra = JanySONField(verbose_name='janyson field', default=dict, 60 | blank=True, null=True) 61 | 62 | def __str__(self): 63 | return "[Item: {}]".format(self.name) 64 | ``` 65 | 66 | ```python 67 | >>> from demo_app.models import Tag, Item 68 | 69 | >>> Tag.objects.create(name='tag1') 70 | >>> Tag.objects.create(name='tag2') 71 | >>> item = Item(name='test') 72 | 73 | >>> item 74 | 75 | >>> item.desc 76 | AttributeError: 'Item' object has no attribute 'desc' 77 | >>> item.qty 78 | 0 79 | >>> print(item.avail) 80 | None 81 | >>> item.tags 82 | AttributeError: 'Item' object has no attribute 'tags' 83 | 84 | >>> tags = Tag.objects.all() 85 | >>> item.desc = 'description' 86 | >>> item.qty = 100 87 | >>> item.avail = True 88 | >>> item.tags = tags 89 | >>> item.save() 90 | 91 | >>> del item 92 | >>> item = Item.objects.get(name='test') 93 | >>> item.desc 94 | 'description' 95 | >>> item.qty 96 | 100 97 | >>> item.avail 98 | True 99 | >>> item.tags 100 | [, , ] 101 | ``` 102 | 103 | ### Tests 104 | 105 | `$ python runtests.py [-d TESTDBNAME] [-h HOSTNAME] [-p PORT] [-U USERNAME] [-P PASSWORD] [-w]` 106 | 107 | Run `python runtests.py --help` for additional info. 108 | 109 | Test with multiple Python versions and measure code coverage (using [tox](https://pypi.python.org/pypi/tox) and [coverage.py](https://pypi.python.org/pypi/coverage)): 110 | 111 | ``` 112 | $ pip install -r requirements_dev.txt 113 | $ ./runtests.sh [TOX_OPTIONS] [-- RUNTESTS_OPTIONS] 114 | ``` 115 | 116 | Example: 117 | 118 | `$ ./runtests.sh -e py27,py35 -- -h 127.0.0.1 -p 5432 -U testuser -w` 119 | 120 | 121 | ### Documentation 122 | 123 | Coming soon. 124 | -------------------------------------------------------------------------------- /janyson/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __version__ = '0.1.1' 4 | 5 | default_app_config = 'janyson.apps.JanysonConfig' 6 | -------------------------------------------------------------------------------- /janyson/apps.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | from django.apps import AppConfig 6 | 7 | 8 | class JanysonConfig(AppConfig): 9 | name = 'janyson' 10 | verbose_name = 'JanySON' 11 | -------------------------------------------------------------------------------- /janyson/decorators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | import six 6 | 7 | from .descriptors import JanySONDescriptor 8 | from .options import Options 9 | from .utils import setdefaultattr 10 | if six.PY2: 11 | from .utils import dir_py2 12 | 13 | 14 | __all__ = ['add_fields'] 15 | 16 | 17 | def dir_override(self): 18 | dir_orig = getattr(self, '_jnsn_dir_orig', None) 19 | if dir_orig: 20 | dir_list = dir_orig() 21 | else: 22 | dir_list = dir_py2(self) if six.PY2 else super().__dir__() 23 | for field in self._jnsn_dir_hidden_fields: 24 | if not hasattr(self, field): 25 | try: 26 | dir_list.remove(field) 27 | except ValueError: # pragma: no cover 28 | pass 29 | return dir_list 30 | 31 | 32 | def add_fields(fields, field_options=None, janyson_field='janyson'): 33 | def decorator(cls): 34 | setdefaultattr(cls, '_jnsn_fields', {}) 35 | setdefaultattr(cls, '_jnsn_settings', {}) 36 | cls._jnsn_settings.setdefault('janyson_field', janyson_field) 37 | if isinstance(fields, dict): 38 | fields_is_dict = True 39 | elif isinstance(fields, (list, tuple)): 40 | fields_is_dict = False 41 | if not isinstance(field_options, dict): 42 | raise ValueError("specify common field options " 43 | "with 'field_options' argument") 44 | options = field_options.copy() 45 | else: 46 | raise TypeError("'fields' must be dict, list, or tuple") 47 | hidden_fields = [] 48 | for field in fields: 49 | if fields_is_dict: 50 | if field_options: 51 | options = field_options.copy() 52 | options.update(fields[field]) 53 | else: 54 | options = fields[field].copy() 55 | cls._jnsn_fields[field] = options 56 | options_obj = Options(options) 57 | if options_obj.dir_hide: 58 | hidden_fields.append(field) 59 | setattr(cls, field, JanySONDescriptor(field, options_obj)) 60 | if hidden_fields: 61 | if hasattr(cls, '_jnsn_dir_hidden_fields'): 62 | cls._jnsn_dir_hidden_fields.extend(hidden_fields) 63 | else: 64 | cls._jnsn_dir_hidden_fields = hidden_fields 65 | dir_orig = getattr(cls, '__dir__', None) 66 | if dir_orig: 67 | cls._jnsn_dir_orig = dir_orig 68 | cls.__dir__ = dir_override 69 | return cls 70 | return decorator 71 | -------------------------------------------------------------------------------- /janyson/descriptors.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | import six 6 | 7 | from django.apps import apps 8 | from django.db.models import QuerySet 9 | 10 | from .options import Options 11 | from .validators import validators 12 | from .utils import setdefaultattr 13 | 14 | 15 | __all__ = ['BaseJanySONDescriptor', 'JanySONDescriptor'] 16 | 17 | 18 | class BaseJanySONDescriptor(object): 19 | 20 | errors = { 21 | 'invalid_json_type': "invalid JSON type {type} " 22 | "(must be dict or None)", 23 | 'invalid_value': "invalid value '{value}' for " 24 | "'{field}' ({type}) field", 25 | 'no_pk': "{instance} has no pk (do you save() it first?)", 26 | 'fk_invalid': "value must be positive integer (primary key) " 27 | "or {model} instance", 28 | 'm2m_invalid': "value must be list or tuple of integers " 29 | "(primary keys) or {model} instances or " 30 | "{model} QuerySet" 31 | } 32 | 33 | def __init__(self, field, options): 34 | self.field = field 35 | if not isinstance(options, Options): 36 | options = Options(options) # pragma: no cover 37 | self.options = Options(options) 38 | 39 | def _get_value(self, obj): 40 | json_dict = self._get_json_dict(obj) 41 | if self.field in json_dict: 42 | value = json_dict[self.field] 43 | elif self.options.use_default: 44 | value = self.options.default 45 | else: 46 | self._raise_attribute_error(obj) 47 | if self.options.type == 'fk' and self.options.use_instance: 48 | value = self._get_model_instance(obj, value) 49 | elif self.options.type == 'm2m' and self.options.use_instance: 50 | value = self._get_model_queryset(obj, value) 51 | return value 52 | 53 | def _set_value(self, obj, value): 54 | json_dict = self._get_json_dict(obj, set_if_none=True) 55 | error = None 56 | type_ = self.options.type 57 | if not validators[type_](value): 58 | error = self.errors['invalid_value'].format( 59 | value=repr(value), field=self.field, type=type_) 60 | if type_ in ('fk', 'm2m') and self.options.use_instance: 61 | model = self._get_model() 62 | if type_ == 'fk': 63 | if not isinstance(value, model): 64 | error += "; "+self.errors['fk_invalid'].format( 65 | model=model.__name__) 66 | else: 67 | if not value.pk: 68 | error += "; "+self.errors['no_pk'].format( 69 | instance=repr(value)) 70 | else: 71 | value = value.pk 72 | error = None 73 | elif type_ == 'm2m': 74 | error_invalid = "; "+self.errors['m2m_invalid'].format( 75 | model=model.__name__) 76 | if isinstance(value, (list, tuple)): 77 | if not all(isinstance(e, model) for e in value): 78 | error += error_invalid 79 | else: 80 | pks = [] 81 | for instance in value: 82 | if not instance.pk: 83 | error += "; "+self.errors['no_pk'].format( 84 | instance=repr(instance)) 85 | break 86 | else: 87 | pks.append(instance.pk) 88 | else: 89 | value = pks 90 | error = None 91 | elif isinstance(value, QuerySet) and value.model == model: 92 | value = [i.pk for i in value] 93 | error = None 94 | else: 95 | error += error_invalid 96 | if error: 97 | raise TypeError(error) 98 | if type_ == 'm2m' and not isinstance(value, six.string_types): 99 | value = list(value) 100 | json_dict[self.field] = value 101 | 102 | def _delete_value(self, obj): 103 | json_dict = self._get_json_dict(obj) 104 | if self.field in json_dict: 105 | del json_dict[self.field] 106 | else: 107 | self._raise_attribute_error(obj) 108 | 109 | if not hasattr(obj, '_jnsn_cache'): 110 | return 111 | if self.options.type == 'fk': 112 | obj._jnsn_cache.pop(self.field+'_instance', None) 113 | elif self.options.type == 'm2m': 114 | obj._jnsn_cache.pop(self.field+'_queryset', None) 115 | obj._jnsn_cache.pop(self.field+'_set', None) 116 | 117 | def _get_json_dict(self, obj, set_if_none=False): 118 | janyson_field = obj._jnsn_settings['janyson_field'] 119 | json_dict = getattr(obj, janyson_field) 120 | if json_dict is None: 121 | json_dict = {} 122 | if set_if_none: 123 | setattr(obj, janyson_field, json_dict) 124 | if isinstance(json_dict, dict): 125 | return json_dict 126 | else: # pragma: no cover 127 | error = self.errors['invalid_json_type'].format( 128 | type=type(json_dict)) 129 | raise TypeError(error) 130 | 131 | def _get_model(self): 132 | model = self.options.model 133 | if isinstance(model, six.string_types): 134 | model = apps.get_model(model) 135 | self.options.model = model 136 | return model 137 | 138 | def _get_model_instance(self, obj, pk): 139 | setdefaultattr(obj, '_jnsn_cache', {}) 140 | instance_key = self.field + '_instance' 141 | instance = obj._jnsn_cache.get(instance_key) 142 | if instance is not None and instance.pk == pk: 143 | return instance 144 | model = self._get_model() 145 | instance = model.objects.get(pk=pk) 146 | obj._jnsn_cache[instance_key] = instance 147 | return instance 148 | 149 | def _get_model_queryset(self, obj, pks): 150 | pks_set = set(pks) 151 | setdefaultattr(obj, '_jnsn_cache', {}) 152 | queryset_key = self.field + '_queryset' 153 | set_key = self.field + '_set' 154 | queryset = obj._jnsn_cache.get(queryset_key) 155 | if queryset is not None and obj._jnsn_cache[set_key] == pks_set: 156 | return queryset 157 | obj._jnsn_cache[set_key] = pks_set 158 | model = self._get_model() 159 | if pks == '*': 160 | queryset = model.objects.all() 161 | elif not pks: 162 | queryset = model.objects.none() 163 | elif pks[0] > 0: 164 | queryset = model.objects.filter(pk__in=pks) 165 | else: 166 | queryset = model.objects.exclude(pk__in=[abs(e) for e in pks]) 167 | obj._jnsn_cache[queryset_key] = queryset 168 | return queryset 169 | 170 | def _raise_attribute_error(self, obj): 171 | raise AttributeError("'{}' object has no attribute '{}'".format( 172 | obj.__class__.__name__, self.field)) 173 | 174 | 175 | class JanySONDescriptor(BaseJanySONDescriptor): 176 | 177 | def __get__(self, obj, objtype): 178 | return self._get_value(obj) 179 | 180 | def __set__(self, obj, value): 181 | self._set_value(obj, value) 182 | 183 | def __delete__(self, obj): 184 | self._delete_value(obj) 185 | -------------------------------------------------------------------------------- /janyson/fields.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | from django.contrib.postgres.fields import JSONField 6 | 7 | from . import forms 8 | 9 | 10 | __all__ = ['JanySONField'] 11 | 12 | 13 | class JanySONField(JSONField): 14 | 15 | def formfield(self, **kwargs): 16 | defaults = {'form_class': forms.JanySONField} 17 | defaults.update(kwargs) 18 | return super(JanySONField, self).formfield(**defaults) 19 | -------------------------------------------------------------------------------- /janyson/forms.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | import json 6 | 7 | import six 8 | from django import forms 9 | from django.utils.translation import ugettext_lazy as _ 10 | from django.contrib.postgres.forms import JSONField 11 | 12 | 13 | __all__ = ['JanySONField'] 14 | 15 | 16 | class JanySONField(JSONField): 17 | default_error_messages = { 18 | 'invalid': _("JSON deserialization error: %(error)s"), 19 | 'invalid_json_type': _("Invalid JSON type"), 20 | 'invalid_value': _("%(field)s (%(type)s)" 21 | " - invalid value: %(value)s"), 22 | 'non_declared_fields': _("Non-declared field(s): %(fields)s"), 23 | } 24 | 25 | def to_python(self, value): 26 | value = value.strip() 27 | if value in self.empty_values: 28 | return None 29 | try: 30 | return json.loads(value) 31 | except ValueError as error: 32 | raise forms.ValidationError( 33 | self.error_messages['invalid'], 34 | code='invalid', 35 | params={'error': error}, 36 | ) 37 | 38 | def prepare_value(self, value): 39 | if isinstance(value, six.text_type): 40 | return value 41 | return json.dumps( 42 | value, 43 | ensure_ascii=False, 44 | indent=4, 45 | separators=(',', ': '), 46 | sort_keys=True 47 | ) 48 | -------------------------------------------------------------------------------- /janyson/locale/en/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-def/django-janyson/49d0b9de2e154ca9aeeb307a32be6a67b0b9705c/janyson/locale/en/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /janyson/locale/en/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2016-02-26 13:58+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | 20 | #: janyson/forms.py:14 21 | #, python-format 22 | msgid "JSON deserialization error: %(error)s" 23 | msgstr "" 24 | 25 | #: janyson/forms.py:15 26 | msgid "Invalid JSON type" 27 | msgstr "" 28 | 29 | #: janyson/fo#, python-formatrms.py:16 30 | #, python-format 31 | msgid "%(field)s (%(type)s) - invalid value: %(value)s" 32 | msgstr "" 33 | 34 | #: janyson/forms.py:18 35 | #, python-format 36 | msgid "Non-declared field(s): %(fields)s" 37 | msgstr "" 38 | -------------------------------------------------------------------------------- /janyson/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-def/django-janyson/49d0b9de2e154ca9aeeb307a32be6a67b0b9705c/janyson/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /janyson/locale/ru/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2016-02-26 13:58+0000\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n" 20 | "%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n" 21 | "%100>=11 && n%100<=14)? 2 : 3);\n" 22 | 23 | #: janyson/forms.py:14 24 | #, python-format 25 | msgid "JSON deserialization error: %(error)s" 26 | msgstr "Ошибка десериализации JSON: %(error)s" 27 | 28 | #: janyson/forms.py:15 29 | msgid "Invalid JSON type" 30 | msgstr "Недопустимый тип JSON" 31 | 32 | #: janyson/forms.py:16 33 | #, python-format 34 | msgid "%(field)s (%(type)s) - invalid value: %(value)s" 35 | msgstr "%(field)s (%(type)s) - недопустимое значение: %(value)s" 36 | 37 | #: janyson/forms.py:18 38 | #, python-format 39 | msgid "Non-declared field(s): %(fields)s" 40 | msgstr "Неизвестные поля: %(fields)s" 41 | -------------------------------------------------------------------------------- /janyson/options.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | 6 | class Options(dict): 7 | 8 | default_options = { 9 | 'use_default': False, 10 | 'default': None, 11 | 'use_instance': True, 12 | 'dir_hide': False, 13 | } 14 | 15 | def __getattr__(self, name): 16 | if name in self: 17 | return self[name] 18 | if name in self.__class__.default_options: 19 | return self.__class__.default_options[name] 20 | self._raise_attribute_error(name) 21 | 22 | def __setattr__(self, name, value): 23 | self[name] = value 24 | 25 | def __delattr__(self, name): 26 | if name in self: 27 | del self[name] 28 | else: 29 | self._raise_attribute_error(name) 30 | 31 | @staticmethod 32 | def _raise_attribute_error(option): 33 | raise AttributeError("Missing option '{}'".format(option)) 34 | -------------------------------------------------------------------------------- /janyson/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | 6 | def setdefaultattr(obj, name, value): 7 | if not hasattr(obj, name): 8 | setattr(obj, name, value) 9 | 10 | 11 | def _get_attrs(obj): 12 | from types import DictProxyType 13 | if not hasattr(obj, '__dict__'): # pragma: no cover 14 | return [] # slots only 15 | if not isinstance(obj.__dict__, 16 | (dict, DictProxyType)): # pragma: no cover 17 | raise TypeError("{}.__dict__ is not a dictionary".format( 18 | obj.__name__)) 19 | return obj.__dict__.keys() 20 | 21 | 22 | # https://stackoverflow.com/questions/ 23 | # 15507848/the-correct-way-to-override-the-dir-method-in-python 24 | def dir_py2(obj): 25 | attrs = set() 26 | if not hasattr(obj, '__bases__'): # obj is an instance 27 | if not hasattr(obj, '__class__'): # slots 28 | return sorted(_get_attrs(obj)) # pragma: no cover 29 | cls = obj.__class__ 30 | attrs.update(_get_attrs(cls)) 31 | else: # obj is a class 32 | cls = obj 33 | for base_cls in cls.__bases__: 34 | attrs.update(_get_attrs(base_cls)) 35 | attrs.update(dir_py2(base_cls)) 36 | attrs.update(_get_attrs(obj)) 37 | return sorted(attrs) 38 | -------------------------------------------------------------------------------- /janyson/validators.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | import six 6 | 7 | 8 | __all__ = ['validators'] 9 | 10 | 11 | def m2m_validator(value): 12 | if isinstance(value, six.string_types): 13 | return value == '*' 14 | if not isinstance(value, (list, tuple)): 15 | return False 16 | if not all(isinstance(e, int) and not isinstance(e, bool) for e in value): 17 | return False 18 | if not (all(e > 0 for e in value) or all(e < 0 for e in value)): 19 | return False 20 | return True 21 | 22 | 23 | validators = { 24 | 'str': lambda v: isinstance(v, six.string_types), 25 | 'num': lambda v: isinstance(v, (int, float)) and not isinstance(v, bool), 26 | 'bool': lambda v: isinstance(v, bool), 27 | 'nullbool': lambda v: v is None or isinstance(v, bool), 28 | 'list': lambda v: isinstance(v, (list, tuple)), 29 | 'dict': lambda v: isinstance(v, dict), 30 | 'fk': lambda v: isinstance(v, int) and not isinstance(v, bool) and v > 0, 31 | 'm2m': m2m_validator, 32 | } 33 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django>=1.9 2 | six>=1.10.0 3 | psycopg2>=2.6.1 4 | -------------------------------------------------------------------------------- /requirements_dev.txt: -------------------------------------------------------------------------------- 1 | tox>=2.3.1 2 | coverage>=4.1 3 | pypandoc>=1.1.3 4 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import sys 5 | import getpass 6 | import argparse 7 | 8 | import django 9 | from django.conf import settings 10 | from django.test.utils import get_runner 11 | 12 | 13 | DEFAULT_DBNAME = 'test_janyson' 14 | DEFAULT_HOST = '/var/run/postgresql' 15 | DEFAULT_PORT = 5432 16 | DEFAULT_USERNAME = getpass.getuser() 17 | 18 | 19 | parser = argparse.ArgumentParser( 20 | description='Django JanySON TestRunner', 21 | add_help=False, 22 | ) 23 | parser.add_argument( 24 | '--help', 25 | action='help', 26 | help='show this help message and exit', 27 | ) 28 | parser.add_argument( 29 | '-d', '--dbname', 30 | default=DEFAULT_DBNAME, 31 | help='test database name (default: "{}")'.format(DEFAULT_DBNAME), 32 | ) 33 | parser.add_argument( 34 | '-h', '--host', 35 | metavar='HOSTNAME', 36 | default=DEFAULT_HOST, 37 | help='database server host or socket directory (default: "{}")'.format( 38 | DEFAULT_HOST), 39 | ) 40 | parser.add_argument( 41 | '-p', '--port', 42 | type=int, 43 | default=DEFAULT_PORT, 44 | help='database server port (default: "{}")'.format(DEFAULT_PORT), 45 | ) 46 | parser.add_argument( 47 | '-U', '--username', 48 | default=DEFAULT_USERNAME, 49 | help='database user name (default: "{}")'.format(DEFAULT_USERNAME), 50 | ) 51 | parser.add_argument( 52 | '-P', '--password', 53 | help='database user password', 54 | ) 55 | parser.add_argument( 56 | '-w', '--no-password', 57 | action='store_true', 58 | help='never prompt for password', 59 | ) 60 | args = parser.parse_args() 61 | 62 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.test_settings' 63 | django.setup() 64 | 65 | if args.password: 66 | password = args.password 67 | elif not args.no_password: 68 | password = getpass.getpass() 69 | else: 70 | password = '' 71 | 72 | db_settings = settings.DATABASES['default'] 73 | db_settings['TEST']['NAME'] = args.dbname 74 | db_settings['HOST'] = args.host 75 | db_settings['PORT'] = args.port 76 | db_settings['USER'] = args.username 77 | db_settings['PASSWORD'] = password 78 | test_runner = get_runner(settings)() 79 | failures = test_runner.run_tests(['tests']) 80 | sys.exit(bool(failures)) 81 | -------------------------------------------------------------------------------- /runtests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | cd "$(dirname "$0")" 3 | rm .coverage 2> /dev/null 4 | tox $* 5 | EXITCODE=$? 6 | [ -f .coverage ] && coverage report -m 7 | echo "\ntox exited with code: $EXITCODE\n" 8 | exit $EXITCODE 9 | 10 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import io 3 | 4 | from setuptools import find_packages, setup 5 | 6 | 7 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 8 | 9 | 10 | version = __import__('janyson').__version__ 11 | 12 | with io.open('README.md', encoding='utf-8') as f: 13 | long_description = f.read() 14 | try: 15 | import pypandoc 16 | long_description = pypandoc.convert(long_description, 'rst', 'md') 17 | long_description = long_description.replace('\r', '') 18 | with io.open('README.rst', mode='w', encoding='utf-8') as f: 19 | f.write(long_description) 20 | except (ImportError, OSError): 21 | print("!!! Can't convert README.md - install pandoc and/or pypandoc.") 22 | 23 | 24 | with io.open('requirements.txt', encoding='utf8') as f: 25 | install_requires = [l.strip() for l in f.readlines() if 26 | l.strip() and not l.startswith('#')] 27 | 28 | 29 | setup( 30 | name='django-janyson', 31 | version=version, 32 | packages=find_packages(exclude=['tests']), 33 | include_package_data=True, 34 | install_requires=install_requires, 35 | license='BSD License', 36 | description='Virtual model fields that are transparently ' 37 | 'mapped to Postgres jsonb', 38 | long_description=long_description, 39 | url='https://github.com/un-def/django-janyson', 40 | author='un.def', 41 | author_email='un.def@ya.ru', 42 | classifiers=[ 43 | 'Environment :: Web Environment', 44 | 'Framework :: Django', 45 | 'Intended Audience :: Developers', 46 | 'License :: OSI Approved :: BSD License', 47 | 'Operating System :: OS Independent', 48 | 'Programming Language :: Python', 49 | 'Programming Language :: Python :: 2', 50 | 'Programming Language :: Python :: 2.7', 51 | 'Programming Language :: Python :: 3', 52 | 'Programming Language :: Python :: 3.4', 53 | 'Programming Language :: Python :: 3.5', 54 | 'Topic :: Internet :: WWW/HTTP', 55 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 56 | ], 57 | ) 58 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/un-def/django-janyson/49d0b9de2e154ca9aeeb307a32be6a67b0b9705c/tests/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.db import models 4 | 5 | from janyson.fields import JanySONField 6 | 7 | 8 | class Item(models.Model): 9 | 10 | name = models.SlugField(max_length=32) 11 | janyson = JanySONField(default=dict, blank=True, null=True) 12 | another_janyson = JanySONField(blank=True, null=True) 13 | 14 | 15 | class Tag(models.Model): 16 | 17 | name = models.SlugField(max_length=32) 18 | 19 | class Meta: 20 | ordering = ['name'] 21 | 22 | 23 | class AnotherModel(models.Model): 24 | 25 | name = models.SlugField(max_length=32) 26 | -------------------------------------------------------------------------------- /tests/test_settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | SECRET_KEY = 'fake-key' 4 | INSTALLED_APPS = [ 5 | 'tests', 6 | ] 7 | DATABASES = { 8 | 'default': { 9 | 'ENGINE': 'django.db.backends.postgresql', 10 | 'NAME': 'not-used', 11 | 'TEST': {}, 12 | }, 13 | } 14 | -------------------------------------------------------------------------------- /tests/tests_forms.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.test import TestCase 4 | from django.forms import modelform_factory, ValidationError 5 | 6 | from janyson.forms import JanySONField 7 | 8 | from .models import Item 9 | 10 | 11 | class FormFieldTestCase(TestCase): 12 | 13 | ItemForm = modelform_factory(Item, fields=['janyson']) 14 | 15 | def test_to_python_valid_value(self): 16 | field = JanySONField() 17 | value = field.clean('{"foo": "bar", "baz": true}') 18 | self.assertEqual(value, {'foo': 'bar', 'baz': True}) 19 | 20 | def test_to_python_invalid_value(self): 21 | field = JanySONField() 22 | with self.assertRaisesRegexp( 23 | ValidationError, "deserialization error"): 24 | field.clean('{"foo": bar') 25 | 26 | def test_to_python_empty_value(self): 27 | field = JanySONField(required=False) 28 | value = field.clean('') 29 | self.assertIsNone(value) 30 | 31 | def test_prepare_value(self): 32 | form = self.ItemForm({'janyson': '[1, "foo", true]'}) 33 | self.assertIn('true\n]', form.as_p()) 34 | 35 | def test_prepare_value_string_no_quotes(self): 36 | form = self.ItemForm({'janyson': '"foo"'}) 37 | self.assertIn('foo', form.as_p()) 38 | -------------------------------------------------------------------------------- /tests/tests_main.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from __future__ import unicode_literals 4 | 5 | from imp import reload 6 | 7 | from django.test import TestCase 8 | from django.db.models import QuerySet 9 | 10 | from janyson.decorators import add_fields 11 | 12 | from . import models 13 | 14 | 15 | def add_fields_to_item_model(*args, **kwargs): 16 | if not kwargs.pop('no_reload', False): 17 | reload(models) 18 | add_fields(*args, **kwargs)(models.Item) 19 | 20 | 21 | TEST_NUM = 10 22 | TEST_STR = 'some text' 23 | TEST_LIST = [1, 3, 5] 24 | TEST_DICT = {'a': 'boo', 'b': 3, 'c': True} 25 | 26 | TEST_NUM_DEFAULT = 3 27 | TEST_STR_DEFAULT = 'default text' 28 | TEST_LIST_DEFAULT = [1, 2] 29 | TEST_DICT_DEFAULT = {'foo': 'bar', 'baz': None} 30 | 31 | 32 | FIELDS_WITHOUT_DEFAULT_VALUES = { 33 | 'test_num': { 34 | 'type': 'num', 35 | }, 36 | 'test_str': { 37 | 'type': 'str', 38 | }, 39 | 'test_bool': { 40 | 'type': 'bool', 41 | 'use_default': False, 42 | 'default': False, 43 | }, 44 | 'test_nullbool': { 45 | 'type': 'bool', 46 | 'use_default': False, 47 | }, 48 | 'test_list': { 49 | 'type': 'list', 50 | }, 51 | 'test_dict': { 52 | 'type': 'dict', 53 | }, 54 | } 55 | 56 | FIELDS_WITH_DEFAULT_VALUES = { 57 | 'test_num': { 58 | 'type': 'num', 59 | 'use_default': True, 60 | 'default': TEST_NUM_DEFAULT, 61 | }, 62 | 'test_str': { 63 | 'type': 'str', 64 | 'use_default': True, 65 | 'default': TEST_STR_DEFAULT, 66 | }, 67 | 'test_bool': { 68 | 'type': 'bool', 69 | 'use_default': True, 70 | 'default': False, 71 | }, 72 | 'test_nullbool': { 73 | 'type': 'bool', 74 | 'use_default': True, 75 | }, 76 | 'test_list': { 77 | 'type': 'list', 78 | 'use_default': True, 79 | 'default': TEST_LIST_DEFAULT, 80 | }, 81 | 'test_dict': { 82 | 'type': 'dict', 83 | 'use_default': True, 84 | 'default': TEST_DICT_DEFAULT, 85 | }, 86 | } 87 | 88 | FIELDS_FK = { 89 | 'fk_instance': { 90 | 'type': 'fk', 91 | 'model': 'tests.Tag', 92 | 'use_instance': True, 93 | }, 94 | 'fk_no_instance': { 95 | 'type': 'fk', 96 | 'model': models.Tag, 97 | 'use_instance': False, 98 | }, 99 | } 100 | 101 | FIELDS_M2M = { 102 | 'm2m_instance': { 103 | 'type': 'm2m', 104 | 'model': 'tests.Tag', 105 | 'use_instance': True, 106 | }, 107 | 'm2m_no_instance': { 108 | 'type': 'm2m', 109 | 'model': models.Tag, 110 | 'use_instance': False, 111 | }, 112 | } 113 | 114 | FIELDS_DIR_HIDE_DEFAULT = { 115 | 'test_default_hide': { 116 | 'type': 'str', 117 | 'use_default': True, 118 | 'default': TEST_STR_DEFAULT, 119 | 'dir_hide': True, 120 | }, 121 | 'test_default_no_hide': { 122 | 'type': 'str', 123 | 'use_default': True, 124 | 'default': TEST_STR_DEFAULT, 125 | 'dir_hide': False, 126 | }, 127 | } 128 | 129 | FIELDS_DIR_HIDE_NO_DEFAULT = { 130 | 'test_no_default_hide': { 131 | 'type': 'str', 132 | 'use_default': False, 133 | 'dir_hide': True, 134 | }, 135 | 'test_no_default_no_hide': { 136 | 'type': 'str', 137 | 'use_default': False, 138 | 'dir_hide': False, 139 | }, 140 | } 141 | 142 | COMMON_FIELD_OPTIONS = { 143 | 'type': 'str', 144 | 'use_default': True, 145 | 'default': TEST_STR_DEFAULT, 146 | } 147 | 148 | COMMON_FIELDS_OVERRIDE = { 149 | 'str1': {}, 150 | 'str2': { 151 | 'use_default': False, 152 | }, 153 | 'num': { 154 | 'type': 'num', 155 | 'default': TEST_NUM_DEFAULT 156 | } 157 | } 158 | 159 | 160 | class StoredValuesTestCase(TestCase): 161 | 162 | JANYSON_FIELD = None 163 | 164 | @classmethod 165 | def setUpClass(cls): 166 | super(StoredValuesTestCase, cls).setUpClass() 167 | kwargs = {} 168 | if cls.JANYSON_FIELD: 169 | kwargs['janyson_field'] = cls.JANYSON_FIELD 170 | add_fields_to_item_model(FIELDS_WITHOUT_DEFAULT_VALUES, **kwargs) 171 | item = models.Item.objects.create(name='stored_values') 172 | item.test_num = TEST_NUM 173 | item.test_str = TEST_STR 174 | item.test_bool = True 175 | item.test_nullbool = True 176 | item.test_list = TEST_LIST 177 | item.test_dict = TEST_DICT 178 | item.save() 179 | cls.item_pk = item.pk 180 | 181 | def setUp(self): 182 | self.item = models.Item.objects.get(pk=self.item_pk) 183 | 184 | def test_num_stored_value(self): 185 | self.assertEqual(self.item.test_num, TEST_NUM) 186 | 187 | def test_str_stored_value(self): 188 | self.assertEqual(self.item.test_str, TEST_STR) 189 | 190 | def test_bool_stored_value(self): 191 | self.assertIs(self.item.test_bool, True) 192 | 193 | def test_nullbool_stored_value(self): 194 | self.assertIs(self.item.test_nullbool, True) 195 | 196 | def test_list_stored_value(self): 197 | self.assertListEqual(self.item.test_list, TEST_LIST) 198 | 199 | def test_dict_stored_value(self): 200 | self.assertDictEqual(self.item.test_dict, TEST_DICT) 201 | 202 | 203 | class StoredValuesInAnotherJanySONFieldTestCase(StoredValuesTestCase): 204 | 205 | JANYSON_FIELD = 'another_janyson' 206 | 207 | 208 | class DefaultValuesTestCase(TestCase): 209 | 210 | @classmethod 211 | def setUpClass(cls): 212 | super(DefaultValuesTestCase, cls).setUpClass() 213 | add_fields_to_item_model(FIELDS_WITH_DEFAULT_VALUES) 214 | item = models.Item.objects.create(name='default_values') 215 | cls.item_pk = item.pk 216 | 217 | def setUp(self): 218 | self.item = models.Item.objects.get(pk=self.item_pk) 219 | 220 | def test_num_default_value(self): 221 | self.assertEqual(self.item.test_num, TEST_NUM_DEFAULT) 222 | 223 | def test_str_default_value(self): 224 | self.assertEqual(self.item.test_str, TEST_STR_DEFAULT) 225 | 226 | def test_bool_default_value(self): 227 | self.assertIs(self.item.test_bool, False) 228 | 229 | def test_nullbool_default_value(self): 230 | self.assertIsNone(self.item.test_nullbool) 231 | 232 | def test_list_default_value(self): 233 | self.assertListEqual(self.item.test_list, TEST_LIST_DEFAULT) 234 | 235 | def test_dict_default_value(self): 236 | self.assertDictEqual(self.item.test_dict, TEST_DICT_DEFAULT) 237 | 238 | 239 | class NoDefaultValuesTestCase(TestCase): 240 | 241 | @classmethod 242 | def setUpClass(cls): 243 | super(NoDefaultValuesTestCase, cls).setUpClass() 244 | add_fields_to_item_model(FIELDS_WITHOUT_DEFAULT_VALUES) 245 | item = models.Item.objects.create(name='no_default_values') 246 | cls.item_pk = item.pk 247 | 248 | def setUp(self): 249 | self.item = models.Item.objects.get(pk=self.item_pk) 250 | 251 | def test_num_no_default_value_error(self): 252 | with self.assertRaises(AttributeError): 253 | self.item.test_num 254 | 255 | def test_str_no_default_value_error(self): 256 | with self.assertRaises(AttributeError): 257 | self.item.test_str 258 | 259 | def test_bool_no_default_value_error(self): 260 | with self.assertRaises(AttributeError): 261 | self.item.test_bool 262 | 263 | def test_nullbool_no_default_value_error(self): 264 | with self.assertRaises(AttributeError): 265 | self.item.test_nullbool 266 | 267 | def test_list_no_default_value_error(self): 268 | with self.assertRaises(AttributeError): 269 | self.item.test_list 270 | 271 | def test_dict_no_default_value_error(self): 272 | with self.assertRaises(AttributeError): 273 | self.item.test_dict 274 | 275 | 276 | class ForeignKeyRelationTestCase(TestCase): 277 | 278 | @classmethod 279 | def setUpClass(cls): 280 | super(ForeignKeyRelationTestCase, cls).setUpClass() 281 | add_fields_to_item_model(FIELDS_FK) 282 | tag1 = models.Tag.objects.create(name='tag1') 283 | tag2 = models.Tag.objects.create(name='tag2') 284 | item = models.Item(name='fk') 285 | item.fk_instance = tag1.pk 286 | item.fk_no_instance = tag2.pk 287 | item.save() 288 | 289 | def setUp(self): 290 | self.item = models.Item.objects.get(name='fk') 291 | 292 | def test_use_instance_true(self): 293 | tag1 = models.Tag.objects.get(name='tag1') 294 | self.assertIsInstance(self.item.fk_instance, models.Tag) 295 | self.assertEqual(self.item.fk_instance, tag1) 296 | 297 | def test_use_instance_false(self): 298 | tag2 = models.Tag.objects.get(name='tag2') 299 | self.assertIsInstance(self.item.fk_no_instance, int) 300 | self.assertEqual(self.item.fk_no_instance, tag2.pk) 301 | 302 | def test_same_model_instance_assignment_use_instance_true(self): 303 | tag = models.Tag.objects.create(name='new tag') 304 | self.item.fk_instance = tag 305 | self.assertEqual(self.item.fk_instance, tag) 306 | 307 | def test_same_model_instance_assignment_use_instance_false(self): 308 | tag = models.Tag.objects.create(name='new tag') 309 | with self.assertRaisesRegexp(TypeError, "invalid value"): 310 | self.item.fk_no_instance = tag 311 | 312 | def test_int_assignment_use_instance_true(self): 313 | tag = models.Tag.objects.create(name='new tag') 314 | self.item.fk_instance = tag.pk 315 | self.assertEqual(self.item.fk_instance, tag) 316 | 317 | def test_int_instance_assignment_use_instance_false(self): 318 | tag = models.Tag.objects.create(name='new tag') 319 | self.item.fk_no_instance = tag.pk 320 | self.assertEqual(self.item.fk_no_instance, tag.pk) 321 | 322 | def test_same_model_instance_assignment_no_pk(self): 323 | tag = models.Tag(name='new tag') 324 | with self.assertRaisesRegexp(TypeError, "no pk"): 325 | self.item.fk_instance = tag 326 | 327 | def test_other_model_instance_assignment(self): 328 | another = models.AnotherModel.objects.create(name='another instance') 329 | with self.assertRaisesRegexp(TypeError, "invalid value"): 330 | self.item.fk_instance = another 331 | 332 | 333 | class ManyToManyRelationTestCase(TestCase): 334 | 335 | @classmethod 336 | def setUpClass(cls): 337 | super(ManyToManyRelationTestCase, cls).setUpClass() 338 | add_fields_to_item_model(FIELDS_M2M) 339 | models.Tag.objects.bulk_create( 340 | models.Tag(name='tag{}'.format(i)) for i in range(1, 5)) 341 | models.Item.objects.create(name='m2m') 342 | 343 | def setUp(self): 344 | self.item = models.Item.objects.get(name='m2m') 345 | self.tags = models.Tag.objects.exclude(name='tag4') 346 | 347 | def test_use_instance_true(self): 348 | self.item.m2m_instance = self.tags 349 | self.assertListEqual(list(self.item.m2m_instance), list(self.tags)) 350 | 351 | def test_use_instance_false(self): 352 | tags_pk = [tag.pk for tag in self.tags] 353 | self.item.m2m_no_instance = tags_pk 354 | self.assertListEqual(self.item.m2m_no_instance, tags_pk) 355 | 356 | def test_same_model_queryset_assignment_use_instance_true(self): 357 | tags = models.Tag.objects.exclude(name='tag1') 358 | self.item.m2m_instance = tags 359 | self.assertIsInstance(self.item.m2m_instance, QuerySet) 360 | self.assertEqual(list(self.item.m2m_instance), list(tags)) 361 | 362 | def test_same_model_queryset_assignment_use_instance_false(self): 363 | tags_pk = [t.pk for t in 364 | models.Tag.objects.exclude(name__in=['tag1', 'tag3'])] 365 | self.item.m2m_no_instance = tags_pk 366 | self.assertIsInstance(self.item.m2m_no_instance, list) 367 | self.assertEqual(self.item.m2m_no_instance, tags_pk) 368 | 369 | def test_int_assignment_use_instance_true(self): 370 | tags_pk = models.Tag.objects.exclude( 371 | name='tag3').values_list('pk', flat=True) 372 | self.item.m2m_instance = list(tags_pk) 373 | self.assertIsInstance(self.item.m2m_instance, QuerySet) 374 | self.assertEqual(list(self.item.m2m_instance), 375 | list(models.Tag.objects.filter(pk__in=tags_pk))) 376 | 377 | def test_int_assignment_use_instance_false(self): 378 | tags_pk = models.Tag.objects.exclude( 379 | name='tag2').values_list('pk', flat=True) 380 | self.item.m2m_no_instance = list(tags_pk) 381 | self.assertIsInstance(self.item.m2m_no_instance, list) 382 | self.assertEqual(self.item.m2m_no_instance, list(tags_pk)) 383 | 384 | def test_neg_int_assignment(self): 385 | exclude_tag_pk = models.Tag.objects.get(name='tag3').pk 386 | tags = models.Tag.objects.exclude(pk=exclude_tag_pk) 387 | self.item.m2m_instance = [-exclude_tag_pk] 388 | self.assertIsInstance(self.item.m2m_instance, QuerySet) 389 | self.assertEqual(list(self.item.m2m_instance), list(tags)) 390 | 391 | def test_asterisk_assignment(self): 392 | tags = models.Tag.objects.all() 393 | self.item.m2m_instance = '*' 394 | self.assertIsInstance(self.item.m2m_instance, QuerySet) 395 | self.assertEqual(list(self.item.m2m_instance), list(tags)) 396 | 397 | def test_empty_assignment(self): 398 | self.item.m2m_instance = [] 399 | self.assertIsInstance(self.item.m2m_instance, QuerySet) 400 | self.assertEqual(list(self.item.m2m_instance), []) 401 | 402 | def test_list_of_instances_assignment(self): 403 | tags = [t for t in models.Tag.objects.all()] 404 | self.item.m2m_instance = tags 405 | self.assertIsInstance(self.item.m2m_instance, QuerySet) 406 | self.assertEqual(list(self.item.m2m_instance), list(tags)) 407 | 408 | def test_list_of_instances_no_pk_assignment(self): 409 | tags = [t for t in models.Tag.objects.all()] 410 | tags.append(models.Tag(name='no pk')) 411 | with self.assertRaisesRegexp(TypeError, "no pk"): 412 | self.item.m2m_instance = tags 413 | 414 | def test_list_of_instances_another_model_assignment(self): 415 | tags = [t for t in models.Tag.objects.all()] 416 | tags.append(models.Item(name='no pk')) 417 | with self.assertRaisesRegexp(TypeError, "invalid value"): 418 | self.item.m2m_instance = tags 419 | 420 | def test_wrong_value_type_assignment(self): 421 | tags = 'foo bar' 422 | with self.assertRaisesRegexp(TypeError, "invalid value"): 423 | self.item.m2m_instance = tags 424 | 425 | 426 | class DeletionTestCase(TestCase): 427 | 428 | @classmethod 429 | def setUpClass(cls): 430 | super(DeletionTestCase, cls).setUpClass() 431 | add_fields_to_item_model(FIELDS_WITH_DEFAULT_VALUES) 432 | add_fields_to_item_model(FIELDS_FK, no_reload=True) 433 | add_fields_to_item_model(FIELDS_M2M, no_reload=True) 434 | models.Item.objects.create(name='deletion') 435 | 436 | def setUp(self): 437 | self.item = models.Item.objects.get(name='deletion') 438 | 439 | def test_set_and_delete(self): 440 | self.item.test_num = TEST_NUM 441 | self.assertEqual(self.item.test_num, TEST_NUM) 442 | del self.item.test_num 443 | self.assertEqual(self.item.test_num, TEST_NUM_DEFAULT) 444 | 445 | def test_delete_already_deleted_from_json(self): 446 | self.item.test_num = TEST_NUM 447 | del self.item.janyson['test_num'] 448 | with self.assertRaisesRegexp(AttributeError, "test_num"): 449 | del self.item.test_num 450 | 451 | def test_delete_fk_cache(self): 452 | tag = models.Tag.objects.create(name='test') 453 | self.item.fk_instance = tag 454 | self.assertFalse(hasattr(self.item, '_jnsn_cache')) 455 | self.item.fk_instance 456 | self.assertEqual(self.item._jnsn_cache['fk_instance_instance'], tag) 457 | del self.item.fk_instance 458 | self.assertNotIn('fk_instance_instance', self.item._jnsn_cache) 459 | 460 | def test_delete_m2m_cache(self): 461 | models.Tag.objects.create(name='test1') 462 | models.Tag.objects.create(name='test2') 463 | tags = models.Tag.objects.all() 464 | self.item.m2m_instance = tags 465 | self.assertFalse(hasattr(self.item, '_jnsn_cache')) 466 | self.item.m2m_instance 467 | self.assertEqual( 468 | list(self.item._jnsn_cache['m2m_instance_queryset']), list(tags)) 469 | del self.item.m2m_instance 470 | self.assertNotIn('m2m_instance_queryset', self.item._jnsn_cache) 471 | 472 | 473 | class DirHideTestCase(TestCase): 474 | 475 | @classmethod 476 | def setUpClass(cls): 477 | super(DirHideTestCase, cls).setUpClass() 478 | add_fields_to_item_model(FIELDS_DIR_HIDE_DEFAULT) 479 | add_fields_to_item_model(FIELDS_DIR_HIDE_NO_DEFAULT, no_reload=True) 480 | 481 | def setUp(self): 482 | self.item = models.Item(name='dir_hide') 483 | 484 | def test_no_value_no_default_no_hide(self): 485 | self.assertIn('test_no_default_no_hide', dir(self.item)) 486 | 487 | def test_no_value_no_default_hide(self): 488 | self.assertNotIn('test_no_default_hide', dir(self.item)) 489 | 490 | def test_value_no_hide(self): 491 | self.item.test_no_default_no_hide = 'foo' 492 | self.assertIn('test_no_default_no_hide', dir(self.item)) 493 | 494 | def test_value_hide(self): 495 | self.item.test_no_default_hide = 'foo' 496 | self.assertIn('test_no_default_hide', dir(self.item)) 497 | 498 | def test_default_no_hide(self): 499 | self.assertIn('test_default_no_hide', dir(self.item)) 500 | 501 | def test_default_hide(self): 502 | self.assertIn('test_default_hide', dir(self.item)) 503 | 504 | 505 | class CommonFieldOptionsTestCase(TestCase): 506 | 507 | @classmethod 508 | def setUpClass(cls): 509 | super(CommonFieldOptionsTestCase, cls).setUpClass() 510 | add_fields_to_item_model( 511 | COMMON_FIELDS_OVERRIDE, field_options=COMMON_FIELD_OPTIONS) 512 | 513 | def setUp(self): 514 | self.item = models.Item(name='common_field_options') 515 | 516 | def test_no_override(self): 517 | self.assertEqual(self.item.str1, TEST_STR_DEFAULT) 518 | 519 | def test_override_use_default(self): 520 | with self.assertRaises(AttributeError): 521 | self.item.str2 522 | 523 | def test_override_type_and_default(self): 524 | self.assertEqual(self.item.num, TEST_NUM_DEFAULT) 525 | 526 | 527 | class AcceptableFieldsArgTypesTestCase(TestCase): 528 | 529 | def test_fields_as_dict_without_common_fields_options(self): 530 | add_fields_to_item_model(COMMON_FIELDS_OVERRIDE) 531 | item = models.Item(name='fields_as_dict') 532 | item.num = TEST_NUM 533 | self.assertEqual(item.num, TEST_NUM) 534 | 535 | def test_fields_as_dict_with_common_fields_options(self): 536 | add_fields_to_item_model( 537 | COMMON_FIELDS_OVERRIDE, field_options=COMMON_FIELD_OPTIONS) 538 | item = models.Item(name='fields_as_dict_with_common') 539 | self.assertEqual(item.str1, TEST_STR_DEFAULT) 540 | 541 | def test_fields_as_list_without_common_fields_options(self): 542 | with self.assertRaisesRegexp(ValueError, "common field options"): 543 | add_fields_to_item_model(['str1', 'str2']) 544 | 545 | def test_fields_as_list_with_common_fields_options(self): 546 | add_fields_to_item_model( 547 | ['str1', 'str2'], field_options=COMMON_FIELD_OPTIONS) 548 | item = models.Item(name='fields_as_list_with_common') 549 | item.str2 = TEST_STR 550 | self.assertEqual(item.str1, TEST_STR_DEFAULT) 551 | self.assertEqual(item.str2, TEST_STR) 552 | 553 | def test_fields_as_str_with_common_fields_options(self): 554 | with self.assertRaisesRegexp(TypeError, "'fields' must be"): 555 | add_fields_to_item_model( 556 | 'str1', field_options=COMMON_FIELD_OPTIONS) 557 | -------------------------------------------------------------------------------- /tests/tests_options.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from django.test import TestCase 4 | 5 | from janyson.options import Options 6 | 7 | 8 | class OptionsTestCase(TestCase): 9 | 10 | def setUp(self): 11 | self.options = Options() 12 | 13 | def test_default_options(self): 14 | for option, value in Options.default_options.items(): 15 | self.assertEqual(getattr(self.options, option), value) 16 | 17 | def test_get_nonexistent(self): 18 | with self.assertRaisesRegexp(AttributeError, "Missing option"): 19 | self.options.key 20 | 21 | def test_set_and_get(self): 22 | self.options.key = 'value' 23 | self.assertEqual(self.options.key, 'value') 24 | 25 | def test_set_and_delete(self): 26 | self.options.key = 'value' 27 | del self.options.key 28 | with self.assertRaisesRegexp(AttributeError, "Missing option"): 29 | self.options.key 30 | 31 | def test_delete_nonexistent(self): 32 | with self.assertRaisesRegexp(AttributeError, "Missing option"): 33 | del self.options.key 34 | -------------------------------------------------------------------------------- /tests/tests_validators.py: -------------------------------------------------------------------------------- 1 | 2 | # -*- coding: utf-8 -*- 3 | 4 | from __future__ import unicode_literals 5 | 6 | import re 7 | from functools import partial 8 | 9 | from django.test import TestCase 10 | 11 | from janyson.validators import validators 12 | 13 | 14 | class ValidatorsTestCase(TestCase): 15 | 16 | TEST_VALUES = { 17 | 'str': 'string', 18 | 'unicode': 'über', 19 | 'pos_int': 123, 20 | 'neg_int': -123, 21 | 'zero': 0, 22 | 'float': .45, 23 | 'true': True, 24 | 'false': False, 25 | 'none': None, 26 | 'list': [0, 1, 2], 27 | 'tuple': (3, 4, 'foo', True), 28 | 'dict': {'foo': 'bar'}, 29 | 'asterisk': '*', 30 | 'pos_int_list': [1, 3, 7], 31 | 'neg_int_list': [-1, -3, -7], 32 | 'mixed_int_list': [1, 3, -7], 33 | } 34 | 35 | RE_METHOD = re.compile('^run_(\w+)_validator_test$') 36 | 37 | def __getattr__(self, attr): 38 | mo = self.RE_METHOD.match(attr) 39 | if mo: 40 | return partial(self._run_validator_test, mo.group(1)) 41 | raise AttributeError(attr) 42 | 43 | def _run_validator_test(self, validator_type, *allowed_value_names): 44 | for value_name, value in self.TEST_VALUES.items(): 45 | if value_name in allowed_value_names: 46 | assert_method = self.assertTrue 47 | else: 48 | assert_method = self.assertFalse 49 | assert_method( 50 | validators[validator_type](value), 51 | "'{}' validator error with value '{}' ({})".format( 52 | validator_type, value, value_name)) 53 | 54 | def test_str_validator(self): 55 | self.run_str_validator_test('str', 'unicode', 'asterisk') 56 | 57 | def test_num_validator(self): 58 | self.run_num_validator_test('pos_int', 'neg_int', 'zero', 'float') 59 | 60 | def test_bool_validator(self): 61 | self.run_bool_validator_test('true', 'false') 62 | 63 | def test_nullbool_validator(self): 64 | self.run_nullbool_validator_test('true', 'false', 'none') 65 | 66 | def test_list_validator(self): 67 | self.run_list_validator_test('list', 'pos_int_list', 'neg_int_list', 68 | 'mixed_int_list', 'tuple') 69 | 70 | def test_dict_validator(self): 71 | self.run_dict_validator_test('dict') 72 | 73 | def test_fk_validator(self): 74 | self.run_fk_validator_test('pos_int') 75 | 76 | def test_m2m_validator(self): 77 | self.run_m2m_validator_test('asterisk', 'pos_int_list', 78 | 'neg_int_list',) 79 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py34, py35 3 | 4 | [testenv] 5 | deps = -rrequirements_dev.txt 6 | commands=coverage run --append runtests.py {posargs} 7 | --------------------------------------------------------------------------------