├── MANIFEST.in ├── rest_easy ├── tests │ ├── __init__.py │ ├── mixins.py │ ├── models.py │ └── test_all.py ├── exceptions.py ├── fields.py ├── registers.py ├── runtests.py ├── models.py ├── __init__.py ├── serializers.py ├── patterns.py ├── scopes.py └── views.py ├── docs ├── index.rst ├── modules.rst.inc ├── Makefile ├── conf.py └── tutorial.rst ├── requirements.txt ├── requirements-2.txt ├── .travis.yml ├── CONTRIBUTORS ├── .gitignore ├── LICENSE ├── setup.py ├── README.md └── .pylintrc /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE.md 3 | -------------------------------------------------------------------------------- /rest_easy/tests/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # pylint: skip-file 3 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ================ 2 | Django Rest Easy 3 | ================ 4 | 5 | .. include:: tutorial.rst 6 | .. include:: modules.rst.inc 7 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Development packages. 2 | coverage 3 | pylint 4 | radon 5 | sphinx 6 | 7 | # Packages required for django-rest-easy to run. 8 | six 9 | django>=1.8.0 10 | djangorestframework>=3.0.0 11 | -------------------------------------------------------------------------------- /requirements-2.txt: -------------------------------------------------------------------------------- 1 | # Development packages. 2 | coverage 3 | pylint 4 | radon 5 | sphinx 6 | 7 | # Packages required for django-rest-easy to run. 8 | six 9 | django>=1.8.0,<2.0 10 | djangorestframework>=3.0.0 11 | -------------------------------------------------------------------------------- /rest_easy/tests/mixins.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # pylint: skip-file 3 | from __future__ import unicode_literals 4 | 5 | 6 | class EmptyMixin(object): 7 | pass 8 | 9 | 10 | class EmptyBase(object): 11 | pass 12 | -------------------------------------------------------------------------------- /rest_easy/exceptions.py: -------------------------------------------------------------------------------- 1 | """ 2 | This module contains exceptions native to django-rest-easy. 3 | """ 4 | 5 | __all__ = ['RestEasyException'] 6 | 7 | 8 | class RestEasyException(Exception): 9 | """ 10 | Default django-rest-easy exception class. 11 | """ 12 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | - "3.4" 5 | - "3.5" 6 | - "3.6" 7 | install: 8 | - if [[ $TRAVIS_PYTHON_VERSION != '2.7' ]]; then pip install -r requirements.txt; fi 9 | - if [[ $TRAVIS_PYTHON_VERSION == '2.7' ]]; then pip install -r requirements-2.txt; fi 10 | script: 11 | - pylint rest_easy --rcfile=.pylintrc 12 | - coverage run --source=rest_easy -m rest_easy.runtests 13 | -------------------------------------------------------------------------------- /rest_easy/tests/models.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # pylint: skip-file 3 | from __future__ import unicode_literals 4 | 5 | from django.db import models 6 | 7 | from rest_easy.models import SerializableMixin 8 | 9 | 10 | class MockModel(SerializableMixin, models.Model): 11 | class Meta: 12 | app_label = 'rest_easy' 13 | 14 | value = models.CharField(max_length=50) 15 | 16 | 17 | class MockModel2(SerializableMixin, models.Model): 18 | class Meta: 19 | app_label = 'rest_easy' 20 | 21 | value = models.CharField(max_length=50) 22 | 23 | 24 | class Account(SerializableMixin, models.Model): 25 | class Meta: 26 | app_label = 'rest_easy' 27 | 28 | 29 | class User(SerializableMixin, models.Model): 30 | class Meta: 31 | app_label = 'rest_easy' 32 | account = models.ForeignKey(Account, on_delete=models.CASCADE) 33 | -------------------------------------------------------------------------------- /CONTRIBUTORS: -------------------------------------------------------------------------------- 1 | Every pull request is important to us. 2 | Whenever you make your first contribution, please add your name and current date to the list below. 3 | It serves two purposes: your work is known to the world and we know that you agree to license us your contributions 4 | under MIT license (which usually is implied, but better safe than sorry). 5 | 6 | Contributor offers to license certain software (a “Contribution” or multiple “Contributions”) 7 | to SMARTPAGER SYSTEMS INC., and SMARTPAGER SYSTEMS INC. agrees to accept said Contributions, 8 | under the terms of the MIT open source license. 9 | Contributor understands and agrees that SMARTPAGER SYSTEMS INC. shall have the irrevocable and perpetual right to make 10 | and distribute copies of any Contribution, as well as to create and distribute collective works and 11 | derivative works of any Contribution, under the MIT License. 12 | 13 | Contributors 14 | ============ 15 | Krzysztof Bujniewicz, 2017-07-06 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | parts/ 18 | sdist/ 19 | var/ 20 | *.egg-info/ 21 | .installed.cfg 22 | *.egg 23 | 24 | # PyInstaller 25 | # Usually these files are written by a python script from a template 26 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 27 | *.manifest 28 | *.spec 29 | 30 | # Installer logs 31 | pip-log.txt 32 | pip-delete-this-directory.txt 33 | 34 | # Unit test / coverage reports 35 | htmlcov/ 36 | .tox/ 37 | .coverage 38 | .coverage.* 39 | .cache 40 | nosetests.xml 41 | coverage.xml 42 | *,cover 43 | 44 | # Translations 45 | *.mo 46 | *.pot 47 | 48 | # Django stuff: 49 | *.log 50 | 51 | # Sphinx documentation 52 | docs/_build/ 53 | *~ 54 | 55 | # PyBuilder 56 | target/ 57 | 58 | # Setuptools 59 | build/ 60 | 61 | # JetBrains 62 | .idea/ 63 | 64 | # PyPI 65 | .pypirc 66 | -------------------------------------------------------------------------------- /rest_easy/fields.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This module contains fields necessary for the django-rest-easy module. 4 | """ 5 | from __future__ import unicode_literals 6 | 7 | from rest_framework.fields import Field 8 | 9 | __all__ = ['StaticField'] 10 | 11 | 12 | class StaticField(Field): # pylint: disable=abstract-method 13 | """ 14 | A field that always provides the same value as output. 15 | 16 | The output value is set on initialization, ie:: 17 | 18 | from rest_easy.serializers import Serializer 19 | 20 | class MySerializer(Serializer): 21 | static = StaticField('This will always be the value.') 22 | 23 | """ 24 | 25 | def __init__(self, value, **kwargs): 26 | """ 27 | Initialize the instance with value and DRF settings. 28 | """ 29 | kwargs['source'] = '*' 30 | kwargs['read_only'] = True 31 | super(StaticField, self).__init__(**kwargs) 32 | self.value = value 33 | 34 | def to_representation(self, value): 35 | """ 36 | Return the static value. 37 | """ 38 | return self.value 39 | -------------------------------------------------------------------------------- /docs/modules.rst.inc: -------------------------------------------------------------------------------- 1 | ******** 2 | API docs 3 | ******** 4 | 5 | .. automodule:: rest_easy 6 | :members: 7 | :undoc-members: 8 | :show-inheritance: 9 | 10 | Exceptions 11 | ========== 12 | 13 | .. automodule:: rest_easy.exceptions 14 | :members: 15 | :undoc-members: 16 | :show-inheritance: 17 | 18 | Fields 19 | ====== 20 | 21 | .. automodule:: rest_easy.fields 22 | :members: 23 | :undoc-members: 24 | :show-inheritance: 25 | 26 | Models 27 | ====== 28 | 29 | .. automodule:: rest_easy.models 30 | :members: 31 | :undoc-members: 32 | :show-inheritance: 33 | 34 | Patterns 35 | ======== 36 | 37 | .. automodule:: rest_easy.patterns 38 | :members: 39 | :undoc-members: 40 | :show-inheritance: 41 | 42 | Registers 43 | ========= 44 | 45 | .. automodule:: rest_easy.registers 46 | :members: 47 | :undoc-members: 48 | :show-inheritance: 49 | 50 | Scopes 51 | ====== 52 | 53 | .. automodule:: rest_easy.scopes 54 | :members: 55 | :undoc-members: 56 | :show-inheritance: 57 | 58 | Serializers 59 | =========== 60 | 61 | .. automodule:: rest_easy.serializers 62 | :members: 63 | :undoc-members: 64 | :show-inheritance: 65 | 66 | Views 67 | ===== 68 | 69 | .. automodule:: rest_easy.views 70 | :members: 71 | :undoc-members: 72 | :show-inheritance: 73 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 SMARTPAGER SYSTEMS INC. 2 | 3 | Devourer is licensed under The MIT License. The license is an OSI approved Open Source 4 | license and is GPL-compatible. 5 | 6 | The license text can also be found here: http://www.opensource.org/licenses/MIT 7 | 8 | License 9 | ======= 10 | 11 | Permission is hereby granted, free of charge, to any person obtaining a copy 12 | of this software and associated documentation files (the "Software"), to deal 13 | in the Software without restriction, including without limitation the rights 14 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 15 | copies of the Software, and to permit persons to whom the Software is 16 | furnished to do so, subject to the following conditions: 17 | 18 | The above copyright notice and this permission notice shall be included in 19 | all copies or substantial portions of the Software. 20 | 21 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 22 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 23 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 24 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 25 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 26 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 27 | THE SOFTWARE. 28 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | setup( 4 | name='django-rest-easy', 5 | packages=['rest_easy'], 6 | version='0.2.1', 7 | python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4', 8 | install_requires=[ 9 | 'django>=1.8.0', 10 | 'djangorestframework>=3.0.0', 11 | 'setuptools>=36.0.1', 12 | 'six', 13 | ], 14 | description='django-rest-easy is an extension to DRF providing QOL improvements to serializers and views.', 15 | long_description='django-rest-easy enables:\n' 16 | ' * versioning serializers by model and schema,\n' 17 | ' * creating views and viewsets using model and schema,\n' 18 | ' * serializer override for a particular DRF verb, like create or update,\n' 19 | ' * scoping views\' querysets and viewsets by url kwargs or request object parameters.', 20 | author='SMARTPAGER SYSTEMS INC. / Krzysztof Bujniewicz', 21 | author_email='racech@gmail.com', 22 | url='https://github.com/TelmedIQ/django-rest-easy', 23 | keywords=['django', 'DRF', 'rest framework', 'serializers', 'viewsets'], 24 | license='MIT', 25 | classifiers=[ 26 | 'Development Status :: 5 - Production/Stable', 27 | 'License :: OSI Approved :: MIT License', 28 | 'Programming Language :: Python :: 2', 29 | 'Programming Language :: Python :: 2.7', 30 | 'Programming Language :: Python :: 3', 31 | 'Programming Language :: Python :: 3.4', 32 | 'Programming Language :: Python :: 3.5', 33 | 'Programming Language :: Python :: 3.6', 34 | ] 35 | ) 36 | -------------------------------------------------------------------------------- /rest_easy/registers.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This module contains the serializer register. 4 | 5 | The serializer register is where all serializers created using 6 | :class:`rest_easy.serializers.SerializerCreator` are registered and where they can be obtained from based 7 | on model and schema. Remember that no other serializers will be kept here - and they will not be obtainable in such 8 | a way. 9 | """ 10 | from __future__ import unicode_literals 11 | 12 | import six 13 | 14 | from rest_easy.exceptions import RestEasyException 15 | from rest_easy.patterns import BaseRegister 16 | 17 | __all__ = ['SerializerRegister', 'serializer_register'] 18 | 19 | 20 | class SerializerRegister(BaseRegister): 21 | """ 22 | Obtains serializer registration name based on model and schema. 23 | """ 24 | @staticmethod 25 | def get_name(model, schema): 26 | """ 27 | Constructs serializer registration name using model's app label, model name and schema. 28 | :param model: a Django model, a ct-like app-model string (app_label.modelname) or explicit None. 29 | :param schema: schema to be used. 30 | :return: constructed serializer registration name. 31 | """ 32 | if model is None: 33 | return schema 34 | if isinstance(model, six.string_types): 35 | return '{}.{}'.format(model, schema) 36 | try: 37 | return '{}.{}.{}'.format(model._meta.app_label, model._meta.model_name, schema) # pylint: disable=protected-access 38 | except AttributeError: 39 | raise RestEasyException('Model must be either None, a ct-like model string or Django model class.') 40 | 41 | def get(self, model, schema): 42 | """ 43 | Shortcut to get serializer having model and schema. 44 | """ 45 | return self.lookup(self.get_name(model, schema)) 46 | 47 | serializer_register = SerializerRegister() 48 | -------------------------------------------------------------------------------- /rest_easy/runtests.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # pylint: skip-file 3 | """ 4 | Tests for django-rest-easy. So far not ported from proprietary code. 5 | """ 6 | from __future__ import unicode_literals 7 | 8 | import os 9 | import sys 10 | 11 | from django.conf import settings 12 | import django 13 | 14 | 15 | if __name__ == '__main__': 16 | settings.configure(DEBUG_PROPAGATE_EXCEPTIONS=True, 17 | DATABASES={ 18 | 'default': { 19 | 'ENGINE': 'django.db.backends.sqlite3', 20 | 'NAME': ':memory:' 21 | } 22 | }, 23 | SITE_ID=1, 24 | SECRET_KEY='not very secret in tests', 25 | USE_I18N=True, 26 | USE_L10N=True, 27 | STATIC_URL='/static/', 28 | TEMPLATES=[ 29 | { 30 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 31 | 'APP_DIRS': True, 32 | }, 33 | ], 34 | MIDDLEWARE_CLASSES=( 35 | 'django.middleware.common.CommonMiddleware', 36 | 'django.contrib.sessions.middleware.SessionMiddleware', 37 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 38 | 'django.contrib.messages.middleware.MessageMiddleware', 39 | ), 40 | INSTALLED_APPS=( 41 | 'django.contrib.auth', 42 | 'django.contrib.contenttypes', 43 | 'django.contrib.sessions', 44 | 'django.contrib.sites', 45 | 'django.contrib.staticfiles', 46 | 'rest_framework', 47 | 'rest_easy', 48 | 'rest_easy.tests' 49 | ), 50 | PASSWORD_HASHERS=( 51 | 'django.contrib.auth.hashers.MD5PasswordHasher', 52 | ), 53 | REST_EASY_VIEW_BASES=['rest_easy.tests.mixins.EmptyBase'], 54 | REST_EASY_GENERIC_VIEW_MIXINS=['rest_easy.tests.mixins.EmptyMixin']) 55 | django.setup() 56 | 57 | parent = os.path.dirname(os.path.abspath(__file__)) 58 | sys.path.insert(0, parent) 59 | 60 | from django.test.runner import DiscoverRunner 61 | 62 | runner_class = DiscoverRunner 63 | test_args = ['tests'] 64 | 65 | failures = runner_class( 66 | verbosity=1, interactive=True, failfast=False).run_tests(test_args) 67 | sys.exit(failures) 68 | -------------------------------------------------------------------------------- /rest_easy/models.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This module provides useful model mixins and global functions. 4 | 5 | Its contents can be used to serialize a model or find proper serializer/deserialize data via a registered serializer. 6 | """ 7 | 8 | from __future__ import unicode_literals 9 | 10 | from rest_easy.exceptions import RestEasyException 11 | from rest_easy.registers import serializer_register 12 | 13 | __all__ = ['SerializableMixin', 'get_serializer', 'deserialize_data'] 14 | 15 | 16 | class SerializableMixin(object): 17 | """ 18 | This mixin provides serializing functionality to Django models. 19 | 20 | The serializing is achieved thanks to serializers registered in 21 | :class:`rest_easy.registers.SerializerRegister`. A proper serializer based on model and provided 22 | schema is obtained from the register and the serialization process is delegated to it. 23 | 24 | Usage: 25 | 26 | ```python 27 | from users.models import User 28 | serializer = User.get_serializer(User.default_schema) 29 | ``` 30 | Or: 31 | 32 | ```python 33 | data = User.objects.all()[0].serialize() 34 | ``` 35 | """ 36 | default_schema = 'default' 37 | 38 | @classmethod 39 | def get_serializer(cls, schema): 40 | """ 41 | Get correct serializer for this model and given schema, 42 | 43 | Utilizes :class:`rest_easy.registers.SerializerRegister` to obtain correct serializer class. 44 | :param schema: schema to be used for serialization. 45 | :return: serializer class. 46 | """ 47 | name = serializer_register.get_name(cls, schema) 48 | return serializer_register.lookup(name) 49 | 50 | def serialize(self, schema=None): 51 | """ 52 | Serialize the model using given or default schema. 53 | :param schema: schema to be used for serialization or self.default_schema 54 | :return: serialized data (a dict). 55 | """ 56 | serializer = self.get_serializer(schema or self.default_schema) 57 | if not serializer: 58 | raise RestEasyException('No serializer found for model {} schema {}'.format(self.__class__, schema)) 59 | return serializer(self).data 60 | 61 | 62 | def get_serializer(data): 63 | """ 64 | Get correct serializer for dict-like data. 65 | 66 | This introspects model and schema fields of the data and passes them to 67 | :class:`rest_easy.registers.SerializerRegister`. 68 | :param data: dict-like object. 69 | :return: serializer class. 70 | """ 71 | if 'model' not in data or 'schema' not in data: 72 | raise RestEasyException('Both model and schema must be provided in data~.') 73 | serializer = serializer_register.lookup(serializer_register.get_name(data['model'], data['schema'])) 74 | if not serializer: 75 | raise RestEasyException('No serializer found for model {} schema {}'.format(data['model'], data['schema'])) 76 | return serializer 77 | 78 | 79 | def deserialize_data(data): 80 | """ 81 | Deserialize dict-like data. 82 | 83 | This function will obtain correct serializer from :class:`rest_easy.registers.SerializerRegister` 84 | using :func:`rest_easy.models.get_serializer`. 85 | :param data: dict-like object or json string. 86 | :return: Deserialized, validated data. 87 | """ 88 | serializer = get_serializer(data)(data=data) 89 | serializer.is_valid(raise_exception=True) 90 | return serializer.validated_data 91 | -------------------------------------------------------------------------------- /rest_easy/__init__.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | Django-rest-easy provides base classes for API views and serializers. 4 | 5 | To leverage the QOL features of django-rest-easy, you should use the followint base classes for your serializers: 6 | 7 | * :class:`rest_easy.serializers.Serializer` 8 | * :class:`rest_easy.serializers.ModelSerializer` 9 | 10 | And if it's model-based, it should use one of the base views provided in the :mod:`rest_easy.views` 11 | module - preferably :class:`rest_easy.views.ModelViewSet` or :class:`rest_easy.views.ReadOnlyModelViewSet`. 12 | 13 | As a baseline, all responses using django-rest-easy extension will contain top-level model and schema fields. 14 | 15 | Guidelines regarding schemas are as usual: they have to be 100% backwards compatible. In the case of breaking changes, 16 | a serializer with new schema should be created, and the old one slowly faded away - and removed only when no 17 | applications use it - or when it's decided that the feature can't be supported anymore. 18 | 19 | An alternative to multi-version fadeout is single-version fadeout, where the change is implemented as a set of 20 | acceptable changes (that is, you can remove the old field only when all clients stop using it - even if it means 21 | sending duplicate data for quite some time). 22 | 23 | The classes from this module don't disable any behaviour inherent to Django Rest Framework - anything that is possible 24 | there will be possible with the django-rest-easy base classes. 25 | 26 | Django Rest Easy uses following settings: 27 | 28 | * REST_EASY_AUTOIMPORT_SERIALIZERS_FROM - for autoimporting serializers. 29 | * REST_EASY_VIEW_BASES - for prepending bases to all views declared in django-rest-easy. They will end up before 30 | all base views, either DRF's or django-rest-easy's, but after generic mixins in the final generic view mro. 31 | So in :class:`rest_easy.views.GenericAPIView` and :class:`rest_easy.views.GenericAPIViewSet` they will be at the 32 | very beginning of the mro, but everything declared in generic mixins, like DRF's CreateMixin, will override that. 33 | * REST_EASY_GENERIC_VIEW_MIXINS - for prepending bases to generic views. They will end up at the beginning of mro 34 | of all generic views available in django-rest-easy. This can be used to make views add parameters when doing 35 | perform_update() or perform_create(). 36 | * REST_EASY_SERIALIZER_CONFLICT_POLICY - what happens when serializer with same model and schema is redefined. Defaults 37 | to 'allow', can also be 'raise' - in the former case the new serializer will replace the old one. Allow is used 38 | to make sure that any import craziness is not creating issues by default. 39 | """ 40 | from django.apps import AppConfig 41 | from django.conf import settings 42 | 43 | default_app_config = 'rest_easy.ApiConfig' 44 | 45 | 46 | class ApiConfig(AppConfig): 47 | """ 48 | AppConfig autoimporting serializers. 49 | 50 | It scans all installed applications for modules specified in settings.REST_EASY_AUTOIMPORT_SERIALIZERS_FROM 51 | parameter, trying to import them so that all residing serializers using 52 | :class:`rest_easy.serializers.SerializerCreator` metaclass will be added to 53 | :class:`rest_easy.registers.SerializerRegister`. 54 | 55 | In the case of a module not being present in app's context, the import is skipped. 56 | In the case of a module existing but failing to import, an exception will be raised. 57 | """ 58 | name = 'rest_easy' 59 | label = 'rest_easy' 60 | 61 | default_paths = ['serializers', 'api.serializers'] 62 | 63 | @property 64 | def paths(self): 65 | """ 66 | Get import paths - from settings or defaults. 67 | """ 68 | return getattr(settings, 'REST_EASY_AUTOIMPORT_SERIALIZERS_FROM', self.default_paths) 69 | 70 | def autodiscover(self): 71 | """ 72 | Auto-discover serializers in installed apps, fail silently when not present, re-raise exception when present 73 | and import fails. Borrowed form django.contrib.admin with added nested presence check. 74 | """ 75 | 76 | from importlib import import_module 77 | from django.apps import apps 78 | from django.utils.module_loading import module_has_submodule 79 | 80 | for app_config in apps.get_app_configs(): 81 | app = app_config.name 82 | mod = import_module(app) 83 | 84 | # Attempt to import the app's serializers. 85 | for item in self.paths: 86 | try: 87 | import_module('{}.{}'.format(app, item)) 88 | 89 | except (TypeError, ImportError): 90 | # Decide whether to bubble up this error. If the app just 91 | # doesn't have serializers module, we can ignore the error 92 | # attempting to import it, otherwise we want it to bubble up. 93 | curr = mod 94 | curr_path = app 95 | for part in item.split('.'): # pragma: no cover 96 | if not module_has_submodule(curr, part): 97 | break 98 | curr_path += '.' + part 99 | curr = import_module(curr_path) 100 | else: # pragma: no cover 101 | raise 102 | 103 | def ready(self): 104 | self.autodiscover() 105 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-rest-easy 2 | ================ 3 | 4 | [![Build Status](https://travis-ci.org/Telmediq/django-rest-easy.svg)](https://travis-ci.org/Telmediq/django-rest-easy) 5 | 6 | Django-rest-easy is an extension to Django Rest Framework providing quality of life improvements to serializers and views 7 | that introduce a more coherent workflow for creating REST APIs: 8 | 9 | * Versioning and referencing serializers by model and schema, along with autoimport, so your serializers will be available anywhere, 10 | as long as you know the model and schema. 11 | * A `StaticField` for adding static data (independent of instance) to serializers. 12 | * Creating views and viewsets using model and schema (it will automatically obtain serializer and queryset, although you can override 13 | both with usual DRF class-level parameters). 14 | * A serializer override for a particular DRF verb, like create or update: no manual get_serialize_class override, no splitting ViewSets 15 | into multiple views. 16 | * Scoping views\' querysets and viewsets by url kwargs or request object parameters. Fore example, when you want to limit messages to 17 | a particular thread or threads to currently logged in user. 18 | * Adding your own base classes to `GenericView` and your own mixins to all resulting generic view classes, like `ListCreateAPIView`. 19 | * Chaining views\' `perform_update` and `perform_create`: they by default pass \*\*kwargs to `serializer.save()` now. 20 | * A helper mixin that enables serializing Django model instances with just an instance method call. 21 | * A helper methods that find serializer class and deserialize a blob of data, since oftentimes you will not know what exact data you will 22 | receive in a particular endpoint, especially when dealing with complex integrations. 23 | 24 | All of the above are possible in pure DRF, but usually introduce a lot of boilerplate or aren\'t very easy or straightforward to code. 25 | Therefore, at Telmediq we decided to open source the package that helps make our API code cleaner and more concise. 26 | 27 | ### Basic usage 28 | 29 | ```python 30 | from django.conf.urls import include, url 31 | from rest_framework.routers import DefaultRouter 32 | 33 | from rest_easy.serializers import ModelSerializer 34 | from rest_easy.views import ModelViewSet 35 | from rest_easy.scopes import UrlKwargScopeQuerySet 36 | from rest_easy.tests.models import Account, User 37 | 38 | class UserSerializer(ModelSerializer): 39 | class Meta: 40 | model = User 41 | schema = 'default' 42 | fields = '__all__' 43 | 44 | class UserViewSet(ModelViewSet): 45 | model = User 46 | schema = 'default' 47 | lookup_url_kwarg = 'pk' 48 | scope = UrlKwargScopeQuerySet(Account) 49 | 50 | router = DefaultRouter() 51 | router.register(r'accounts/(?P\d+)/users', UserViewSet) 52 | 53 | urlpatterns = [url(r'^', include(router.urls))] 54 | ``` 55 | 56 | Installation 57 | ------------ 58 | `pip install django-rest-easy` and add rest_easy to installed apps in Django settings: 59 | 60 | ```python 61 | INSTALLED_APPS = ( 62 | # ... 63 | 'rest_framework', 64 | 'rest_easy', 65 | # ... 66 | ) 67 | ``` 68 | 69 | The settings used are: 70 | 71 | * REST_EASY_AUTOIMPORT_SERIALIZERS_FROM - specify modules or packages that rest-easy will try to import serializers 72 | from when AppConfig is ready. The import is app-based, so it will search for serializers in all installed apps. 73 | By default `['serializers']` 74 | * REST_EASY_VIEW_BASES - your mixins that should go into all views near the end of the mro (before all DRF and 75 | django-rest-easy's bases, after all generic mixins from DRF). 76 | * REST_EASY_GENERIC_VIEW_MIXINS - your mixins that should go into all generic views at the beginning of the mro 77 | (that means CreateAPIView, ListAPIView, RetrieveAPIView, DestroyAPIView, UpdateAPIView, ListCreateAPIView, 78 | RetrieveUpdateAPIView, RetrieveDestroyAPIView, RetrieveUpdateDestroyAPIView, ReadOnlyModelViewSet, 79 | ModelViewSet). 80 | * REST_EASY_SERIALIZER_CONFLICT_POLICY - either 'allow' or 'raise'. What should happen when you redeclare a serializer 81 | with same model and schema - either the new one will be used or an error will be raised. By default 'allow' to not 82 | break applications with weird imports. 83 | 84 | Documentation 85 | ------------- 86 | 87 | Feel free to browse the code and especially the tests to see what's going on behind the scenes. 88 | The current version of docs is available on http://django-rest-easy.readthedocs.org/en/latest/. 89 | 90 | Questions and contact 91 | --------------------- 92 | 93 | If you have any questions, feedback, want to say hi or talk about Python, just hit me up on 94 | https://twitter.com/bujniewicz 95 | 96 | Contributions 97 | ------------- 98 | 99 | Please read CONTRIBUTORS file before submitting a pull request. 100 | 101 | We use Travis CI. The targets are 10.00 for lint and non-decreasing coverage (currently at 100%), as well as 102 | building sphinx docs. 103 | 104 | You can also check the build manually, just make sure to `pip install -r requirements.txt` before: 105 | 106 | ``` 107 | pylint rest_easy --rcfile=.pylintrc 108 | coverage run --source=rest_easy -m rest_easy.runtests && coverage report -m 109 | cd docs && make html 110 | ``` 111 | 112 | Additionally you can check cyclomatic complexity and maintenance index with radon: 113 | 114 | ``` 115 | radon cc rest_easy 116 | radon mi rest_easy 117 | ``` 118 | 119 | The target is A for maintenance index, B for cyclomatic complexity - but don't worry if it isn't met, I can 120 | refactor it after merging. 121 | -------------------------------------------------------------------------------- /rest_easy/serializers.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This module contains base serializers to be used with django-rest-easy. 4 | 5 | Crucial point of creating a good API is format consistency. If you've been lacking that so far, can't afford it anymore 6 | or want to make your life easier, you can enforce a common message format and a common serialization format. 7 | Enter the following SerializerCreator - it will make sure that everything serializers output will contain schema 8 | and model fields. This affects both regular and model serializers. 9 | 10 | Additional benefit of using such metaclass is serializer registration - we can easily obtain serializers based on 11 | model (or None for non-model serializers) and schema from anywhere in the application. That's useful in several cases: 12 | 13 | * model serialization 14 | * remote data deserialization (no changes to (de)serialization logic required when we add a new schema) 15 | * simpler views and viewsets 16 | 17 | This doesn't disable any DRF's serializers functionality. 18 | """ 19 | from __future__ import unicode_literals 20 | 21 | import six 22 | from django.db import models 23 | from rest_framework.serializers import (Serializer as OSerializer, 24 | ModelSerializer as OModelSerializer, 25 | SerializerMetaclass, 26 | Field) 27 | 28 | from rest_easy.fields import StaticField 29 | from rest_easy.registers import serializer_register 30 | from rest_easy.patterns import RegisteredCreator 31 | 32 | __all__ = ['ModelSerializer', 'Serializer', 'RegisterableSerializerMixin', 'SerializerCreator'] 33 | 34 | 35 | class SerializerCreator(RegisteredCreator, SerializerMetaclass): 36 | """ 37 | This metaclass creates serializer classes to be used with django-rest-easy. 38 | 39 | We need to employ multiple inheritance here (if the behaviour ever needs to be overridden, you can just use both 40 | base classes to implement your own functionality) to preserve DRF's behaviour regarding 41 | serializer fields as well as registration and required fields checking from our own metaclass. 42 | 43 | Remember that all __new__ methods from base classes get called. 44 | """ 45 | inherit_fields = False 46 | register = serializer_register 47 | required_fields = { 48 | 'Meta': { 49 | 'model': lambda value: value is None or issubclass(value, models.Model), 50 | 'schema': lambda value: isinstance(value, six.string_types) 51 | } 52 | } 53 | 54 | @staticmethod 55 | def get_fields_from_base(base): 56 | """ 57 | Alteration of original fields inheritance. 58 | 59 | It skips all serializer fields, since SerializerMetaclass deals with that already. 60 | :param base: a base class. 61 | :return: generator of (name, value) tuples of fields from base. 62 | """ 63 | for item in dir(base): 64 | # Avoid copying serializer fields to class, since DRF's metaclass deals with that already. 65 | if not item.startswith('_') and not isinstance(item, Field): 66 | value = getattr(base, item) 67 | if not callable(value): 68 | yield item, getattr(base, item) 69 | 70 | @staticmethod 71 | def get_name(name, bases, attrs): 72 | """ 73 | Alteration of original get_name. 74 | 75 | This, instead of returing class's name, obtains correct serializer registration name from 76 | :class:`rest_easy.registers.SerializerRegister` and uses it as slug for registration purposes. 77 | :param name: class name. 78 | :param bases: class bases. 79 | :param attrs: class attributes. 80 | :return: registered serializer name. 81 | """ 82 | model = attrs['Meta'].model 83 | return serializer_register.get_name(model, attrs['Meta'].schema) 84 | 85 | @classmethod 86 | def pre_register(mcs, name, bases, attrs): 87 | """ 88 | Pre-register hook adding required fields 89 | 90 | This is the place to add required fields if they haven't been declared explicitly. 91 | We're adding model and schema fields here. 92 | :param name: class name. 93 | :param bases: class bases. 94 | :param attrs: class attributes. 95 | :return: tuple of altered name, bases, attrs. 96 | """ 97 | if 'model' not in attrs: 98 | model = attrs['Meta'].model 99 | if model: 100 | model_name = '{}.{}'.format(model._meta.app_label, model._meta.object_name) # pylint: disable=protected-access 101 | else: 102 | model_name = None 103 | attrs['model'] = StaticField(model_name) 104 | if 'schema' not in attrs: 105 | attrs['schema'] = StaticField(attrs['Meta'].schema) 106 | if hasattr(attrs['Meta'], 'fields'): 107 | if not isinstance(attrs['Meta'].fields, six.string_types): 108 | attrs['Meta'].fields = list(attrs['Meta'].fields) 109 | if 'model' not in attrs['Meta'].fields: 110 | attrs['Meta'].fields.append('model') 111 | if 'schema' not in attrs['Meta'].fields: 112 | attrs['Meta'].fields.append('schema') 113 | return name, bases, attrs 114 | 115 | 116 | class RegisterableSerializerMixin(six.with_metaclass(SerializerCreator, object)): # pylint: disable=too-few-public-methods 117 | """ 118 | A mixin to be used if you want to inherit functionality from non-standard DRF serializer. 119 | """ 120 | __abstract__ = True 121 | 122 | 123 | class Serializer(six.with_metaclass(SerializerCreator, OSerializer)): # pylint: disable=too-few-public-methods,abstract-method 124 | """ 125 | Registered version of DRF's Serializer. 126 | """ 127 | __abstract__ = True 128 | 129 | 130 | class ModelSerializer(six.with_metaclass(SerializerCreator, OModelSerializer)): # pylint: disable=too-few-public-methods,abstract-method 131 | """ 132 | Registered version of DRF's ModelSerializer. 133 | """ 134 | __abstract__ = True 135 | -------------------------------------------------------------------------------- /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 coverage 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 " applehelp to make an Apple Help Book" 34 | @echo " devhelp to make HTML files and a Devhelp project" 35 | @echo " epub to make an epub" 36 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 37 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 38 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 39 | @echo " text to make text files" 40 | @echo " man to make manual pages" 41 | @echo " texinfo to make Texinfo files" 42 | @echo " info to make Texinfo files and run them through makeinfo" 43 | @echo " gettext to make PO message catalogs" 44 | @echo " changes to make an overview of all changed/added/deprecated items" 45 | @echo " xml to make Docutils-native XML files" 46 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 47 | @echo " linkcheck to check all external links for integrity" 48 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 49 | @echo " coverage to run coverage check of the documentation (if enabled)" 50 | 51 | clean: 52 | rm -rf $(BUILDDIR)/* 53 | 54 | html: 55 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 56 | @echo 57 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 58 | 59 | dirhtml: 60 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 61 | @echo 62 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 63 | 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | pickle: 70 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 71 | @echo 72 | @echo "Build finished; now you can process the pickle files." 73 | 74 | json: 75 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 76 | @echo 77 | @echo "Build finished; now you can process the JSON files." 78 | 79 | htmlhelp: 80 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 81 | @echo 82 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 83 | ".hhp project file in $(BUILDDIR)/htmlhelp." 84 | 85 | qthelp: 86 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 87 | @echo 88 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 89 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 90 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/rest_easy.qhcp" 91 | @echo "To view the help file:" 92 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/rest_easy.qhc" 93 | 94 | applehelp: 95 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 96 | @echo 97 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 98 | @echo "N.B. You won't be able to view it unless you put it in" \ 99 | "~/Library/Documentation/Help or install it in your application" \ 100 | "bundle." 101 | 102 | devhelp: 103 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 104 | @echo 105 | @echo "Build finished." 106 | @echo "To view the help file:" 107 | @echo "# mkdir -p $$HOME/.local/share/devhelp/rest_easy" 108 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/rest_easy" 109 | @echo "# devhelp" 110 | 111 | epub: 112 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 113 | @echo 114 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 115 | 116 | latex: 117 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 118 | @echo 119 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 120 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 121 | "(use \`make latexpdf' here to do that automatically)." 122 | 123 | latexpdf: 124 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 125 | @echo "Running LaTeX files through pdflatex..." 126 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 127 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 128 | 129 | latexpdfja: 130 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 131 | @echo "Running LaTeX files through platex and dvipdfmx..." 132 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 133 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 134 | 135 | text: 136 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 137 | @echo 138 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 139 | 140 | man: 141 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 142 | @echo 143 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 144 | 145 | texinfo: 146 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 147 | @echo 148 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 149 | @echo "Run \`make' in that directory to run these through makeinfo" \ 150 | "(use \`make info' here to do that automatically)." 151 | 152 | info: 153 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 154 | @echo "Running Texinfo files through makeinfo..." 155 | make -C $(BUILDDIR)/texinfo info 156 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 157 | 158 | gettext: 159 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 160 | @echo 161 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 162 | 163 | changes: 164 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 165 | @echo 166 | @echo "The overview file is in $(BUILDDIR)/changes." 167 | 168 | linkcheck: 169 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 170 | @echo 171 | @echo "Link check complete; look for any errors in the above output " \ 172 | "or in $(BUILDDIR)/linkcheck/output.txt." 173 | 174 | doctest: 175 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 176 | @echo "Testing of doctests in the sources finished, look at the " \ 177 | "results in $(BUILDDIR)/doctest/output.txt." 178 | 179 | coverage: 180 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 181 | @echo "Testing of coverage in the sources finished, look at the " \ 182 | "results in $(BUILDDIR)/coverage/python.txt." 183 | 184 | xml: 185 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 186 | @echo 187 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 188 | 189 | pseudoxml: 190 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 191 | @echo 192 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 193 | -------------------------------------------------------------------------------- /rest_easy/patterns.py: -------------------------------------------------------------------------------- 1 | """ 2 | This class defines generic bases for a few design / architectural patterns 3 | required by django-rest-easy, namely singleton and register. 4 | """ 5 | 6 | from functools import wraps 7 | from six import with_metaclass 8 | 9 | from rest_easy.exceptions import RestEasyException 10 | 11 | __all__ = ['SingletonCreator', 'SingletonBase', 'Singleton', 'BaseRegister', 'RegisteredCreator'] 12 | 13 | class SingletonCreator(type): 14 | """ 15 | This metaclass wraps __init__ method of created class with singleton_decorator. 16 | This ensures that it's impossible to mess up the instance for example by 17 | calling __init__ with getattr. 18 | """ 19 | 20 | @staticmethod 21 | def singleton_decorator(func): 22 | """ 23 | We embed given function into checking if the first (zeroth) parameter of its call 24 | shall be initialised. 25 | :param func: instantiating function (usually __init__). 26 | :returns: embedded function function. 27 | """ 28 | 29 | @wraps(func) 30 | def wrapper(*args, **kwargs): 31 | """ 32 | This inner function checks init property of given instance and depending on its 33 | value calls the function or not. 34 | """ 35 | if args[0].sl_init: 36 | return func(*args, **kwargs) 37 | 38 | return wrapper 39 | 40 | def __new__(mcs, name, bases, attrs): 41 | """ 42 | Wraps are awesome. Sometimes. 43 | """ 44 | if not (len(bases) == 1 and object in bases): 45 | if '__init__' in attrs: 46 | attrs['__init__'] = mcs.singleton_decorator(attrs['__init__']) 47 | return super(SingletonCreator, mcs).__new__(mcs, name, bases, attrs) 48 | 49 | 50 | class SingletonBase(object): # pylint: disable=too-few-public-methods 51 | """ 52 | This class implements the singleton pattern using a metaclass and 53 | overriding default __new__ magic method's behaviour. It works together with 54 | SingletonCreator metaclass to create a Singleton base class. 55 | sl_init property is reserved, you can't use it in inheriting classes. 56 | """ 57 | 58 | _instance = None 59 | 60 | def __new__(cls, *_): 61 | """ 62 | This magic method override makes sure that only one instance will be created. 63 | """ 64 | if not isinstance(cls._instance, cls): 65 | cls._instance = super(SingletonBase, cls).__new__(cls) 66 | cls._instance.sl_init = True 67 | else: 68 | cls._instance.sl_init = False 69 | return cls._instance 70 | 71 | 72 | class Singleton(with_metaclass(SingletonCreator, SingletonBase)): # pylint: disable=too-few-public-methods 73 | """ 74 | This is a Singleton you can inherit from. 75 | It reserves sl_init instance attribute to work properly. 76 | """ 77 | pass 78 | 79 | 80 | class BaseRegister(Singleton): 81 | """ 82 | This class is a base register-type class. You should inherit from it to create particular registers. 83 | 84 | conflict_policy is a setting deciding what to do in case of name collision (registering another 85 | entity with the same name). It should be one of: 86 | 87 | * allow - replace old entry with new entry, return True, 88 | * deny - leave old entry, return False, 89 | * raise - raise RestEasyException. 90 | 91 | Default policy is raise. 92 | 93 | As this is a singleton, instantiating a particular children class in any place will yield the exact same data 94 | as the register instance used in RegisteredCreator(). 95 | """ 96 | conflict_policy = 'allow' 97 | 98 | @classmethod 99 | def get_conflict_policy(cls): 100 | """ 101 | Obtain conflict policy from django settings or use default. 102 | 103 | Allowed settings are 'raise' and 'allow'. Default is 'raise'. 104 | """ 105 | from django.conf import settings 106 | return getattr(settings, 'REST_EASY_SERIALIZER_CONFLICT_POLICY', cls.conflict_policy) 107 | 108 | def __init__(self): 109 | """ 110 | We create an empty model dict. 111 | """ 112 | self._entries = {} 113 | self.connect = lambda: None 114 | 115 | def register(self, name, ref): 116 | """ 117 | Register an entry, shall we? 118 | :param name: entry name. 119 | :param ref: entry value (probably class). 120 | :returns: True if model was added just now, False if it was already in the register. 121 | """ 122 | if not self.lookup(name) or self.get_conflict_policy() == 'allow': 123 | self._entries[name] = ref 124 | return True 125 | else: 126 | raise RestEasyException('Entry named {} is already registered.'.format(name)) 127 | 128 | def lookup(self, name): 129 | """ 130 | I like to know if an entry is in the register, don't you? 131 | :param name: name to check. 132 | :returns: True if entry with given name is in the register, False otherwise. 133 | """ 134 | return self._entries.get(name, None) 135 | 136 | def entries(self): 137 | """ 138 | Return an iterator over all registered entries. 139 | """ 140 | return self._entries.items() 141 | 142 | 143 | class RegisteredCreator(type): 144 | """ 145 | This metaclass integrates classes with a BaseRegister subclass. 146 | 147 | It skips processing base/abstract classes, which have __abstract__ property 148 | evaluating to True. 149 | """ 150 | register = None 151 | required_fields = set() 152 | inherit_fields = False 153 | 154 | @staticmethod 155 | def get_name(name, bases, attrs): # pylint: disable=unused-argument 156 | """ 157 | Get name to be used for class registration. 158 | """ 159 | return name 160 | 161 | @staticmethod 162 | def get_fields_from_base(base): 163 | """ 164 | Obtains all fields from the base class. 165 | :param base: base class. 166 | :return: generator of (name, value) tuples. 167 | """ 168 | for item in dir(base): 169 | if not item.startswith('_'): 170 | value = getattr(base, item) 171 | if not callable(value): 172 | yield item, getattr(base, item) 173 | 174 | @classmethod 175 | def process_required_field(mcs, missing, fields, name, value): 176 | """ 177 | Processes a single required field to check if it applies to constraints. 178 | """ 179 | try: 180 | if not hasattr(fields, name) and name not in fields: 181 | missing.append(name) 182 | return 183 | except TypeError: 184 | missing.append(name) 185 | return 186 | if value: 187 | if hasattr(fields, name): 188 | inner = getattr(fields, name) 189 | else: 190 | inner = fields[name] 191 | if callable(value): 192 | if not value(inner): 193 | missing += [name] 194 | else: 195 | missing += [name + '.' + item for item in mcs.get_missing_fields(value, inner)] 196 | 197 | @classmethod 198 | def get_missing_fields(mcs, required_fields, fields): 199 | """ 200 | Lists required fields that are missing. 201 | 202 | Supports two formats of input of required fields: either a simple set {'a', 'b'} or a dict with several 203 | options:: 204 | 205 | { 206 | 'nested': { 207 | 'presence_check_only': None, 208 | 'functional_check': lambda value: isinstance(value, Model) 209 | }, 210 | 'flat_presence_check': None, 211 | 'flat_functional_check': lambda value: isinstance(value, Model) 212 | } 213 | 214 | Functional checks need to return true for field not to be marked as missing. 215 | Dict-format also supports both dict and attribute based accesses for fields (fields['a'] and fields.a). 216 | 217 | :param required_fields: set or dict of required fields. 218 | :param fields: dict or object of actual fields. 219 | :return: List of missing fields. 220 | """ 221 | if isinstance(required_fields, set): 222 | return [field for field in required_fields if field not in fields or not field] 223 | 224 | missing = [] 225 | for name, value in required_fields.items(): 226 | mcs.process_required_field(missing, fields, name, value) 227 | return missing 228 | 229 | @classmethod 230 | def pre_register(mcs, name, bases, attrs): 231 | """ 232 | Pre-register hook. 233 | :param name: class name. 234 | :param bases: class bases. 235 | :param attrs: class attributes. 236 | :return: Modified tuple (name, bases, attrs) 237 | """ 238 | return name, bases, attrs 239 | 240 | @classmethod 241 | def post_register(mcs, cls, name, bases, attrs): 242 | """ 243 | Post-register hook. 244 | :param cls: created class. 245 | :param name: class name. 246 | :param bases: class bases. 247 | :param attrs: class attributes. 248 | :return: None. 249 | """ 250 | pass 251 | 252 | def __new__(mcs, name, bases, attrs): 253 | """ 254 | This method creates and registers new class, if it's not already 255 | in the register. 256 | """ 257 | # Do not register the base classes, which actual classes inherit. 258 | if mcs.inherit_fields: 259 | for base in bases: 260 | for field, value in mcs.get_fields_from_base(base): 261 | if field not in attrs: 262 | attrs[field] = value 263 | if not attrs.get('__abstract__', False): 264 | missing = mcs.get_missing_fields(mcs.required_fields, attrs) 265 | if missing: 266 | raise RestEasyException( 267 | 'The following mandatory fields are missing from {} class definition: {}'.format( 268 | name, 269 | ', '.join(missing) 270 | ) 271 | ) 272 | name, bases, attrs = mcs.pre_register(name, bases, attrs) 273 | slug = mcs.get_name(name, bases, attrs) 274 | cls = super(RegisteredCreator, mcs).__new__(mcs, name, bases, attrs) 275 | mcs.register.register(slug, cls) 276 | mcs.post_register(cls, name, bases, attrs) 277 | else: 278 | cls = super(RegisteredCreator, mcs).__new__(mcs, name, bases, attrs) 279 | return cls 280 | -------------------------------------------------------------------------------- /rest_easy/scopes.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | """ 3 | This module provides scopes usable with django-rest-easy's generic views. 4 | 5 | See :mod:`rest_easy.views` for detailed explanation. 6 | """ 7 | from __future__ import unicode_literals 8 | 9 | from django.db.models import QuerySet, Model 10 | from django.http import Http404 11 | from django.shortcuts import get_object_or_404 12 | 13 | from rest_easy.exceptions import RestEasyException 14 | 15 | __all__ = ['ScopeQuerySet', 'UrlKwargScopeQuerySet', 'RequestAttrScopeQuerySet'] 16 | 17 | 18 | class ScopeQuerySet(object): 19 | """ 20 | This class provides a scope-by-parent-element functionality to views and their querysets. 21 | 22 | It works by selecting a proper parent model instance and filtering view's queryset with it automatically. 23 | """ 24 | 25 | def __init__(self, qs_or_obj, parent_field='pk', related_field=None, raise_404=False, allow_none=False, 26 | get_object_handle='', parent=None): 27 | """ 28 | Sets instance properties, infers sane defaults and ensures qs_or_obj is correct. 29 | 30 | :param qs_or_obj: This can be a queryset or a Django model or explicit None (for particular subclasses) 31 | :param parent_field: the field to filter by in the parent queryset (qs_or_obj), by default 'id'. 32 | :param related_field: the field to filter by in the view queryset, by default model_name. 33 | :param raise_404: whether 404 should be raised if parent object cannot be found. 34 | :param allow_none: if filtering view queryset by object=None should be allowed. If it's false, resulting 35 | queryset is guaranteed to be empty if parent object can't be found and 404 is not raised. 36 | :param get_object_handle: the name under which the object should be available in view. ie. 37 | view.get_scoped_object(get_object_handle) or view.get_{get_object_handle}. If None, the object will 38 | not be available from view level. By default will be infered to qs_or_obj's model_name. 39 | :param parent: if this object's queryset should be filtered by another parameter, parent attribute should be 40 | an instance of ScopeQuerySet. This allows for ScopeQuerySetChaining (ie. for messages we might have 41 | UrlKwargScopeQuerySet(User, parent=UrlKwargScopeQuerySet(Account)) for scoping by user and limiting users 42 | to an account. 43 | """ 44 | if isinstance(qs_or_obj, QuerySet): 45 | self.queryset = qs_or_obj 46 | elif isinstance(qs_or_obj, type) and issubclass(qs_or_obj, Model): 47 | self.queryset = qs_or_obj.objects.all() 48 | elif qs_or_obj is None: 49 | self.queryset = None 50 | else: 51 | raise RestEasyException('Queryset parameter must be an instance of QuerySet or a Model subclass.') 52 | 53 | if related_field is None: 54 | try: 55 | related_field = '{}'.format(self.queryset.model._meta.model_name) # pylint: disable=protected-access 56 | except AttributeError: 57 | raise RestEasyException('Either related_field or qs_or_obj must be given.') 58 | self.parent_field = parent_field 59 | self.related_field = related_field 60 | self.raise_404 = raise_404 61 | self.parent = ([parent] if isinstance(parent, ScopeQuerySet) else parent) or [] 62 | self.allow_none = allow_none 63 | self.get_object_handle = get_object_handle 64 | if self.get_object_handle == '': 65 | try: 66 | self.get_object_handle = self.queryset.model._meta.model_name # pylint: disable=protected-access 67 | except AttributeError: 68 | raise RestEasyException('Either qs_or_obj or explicit get_object_handle (can be None) must be given.') 69 | 70 | def contribute_to_class(self, view): 71 | """ 72 | Put self.get_object_handle into view's available handles dict to allow easy access to the scope's get_object() 73 | method in case the object needs to be reused (ie. in child object creation). 74 | :param view: View the scope is added to. 75 | """ 76 | if self.get_object_handle: 77 | if self.get_object_handle in view.rest_easy_available_object_handles: 78 | raise RestEasyException( 79 | 'ImproperlyConfigured: multiple scopes with {} get_object handle!'.format(self.get_object_handle) 80 | ) 81 | view.rest_easy_available_object_handles[self.get_object_handle] = self 82 | for scope in self.parent: 83 | scope.contribute_to_class(view) 84 | 85 | def get_value(self, view): 86 | """ 87 | Get value used to filter qs_or_objs's field specified for filtering (parent_field in init). 88 | :param view: DRF view instance - as it provides access to both request and kwargs. 89 | :return: value to filter by. 90 | """ 91 | raise NotImplementedError('You need to use ScopeQueryset subclass with get_value implemented.') 92 | 93 | def get_queryset(self, view): 94 | """ 95 | Obtains parent queryset (init's qs_or_obj) along with any chaining (init's parent) required. 96 | :param view: DRF view instance. 97 | :return: queryset instance. 98 | """ 99 | queryset = self.queryset 100 | for parent in self.parent: 101 | queryset = parent.child_queryset(queryset, view) 102 | return queryset 103 | 104 | def get_object(self, view): 105 | """ 106 | Caching wrapper around _get_object. 107 | :param view: DRF view instance. 108 | :return: object (instance of init's qs_or_obj model except shadowed by subclass). 109 | """ 110 | if self.get_object_handle: 111 | obj = view.rest_easy_object_cache.get(self.get_object_handle, None) 112 | if not obj: 113 | obj = self._get_object(view) 114 | view.rest_easy_object_cache[self.get_object_handle] = obj 115 | else: 116 | obj = self._get_object(view) 117 | return obj 118 | 119 | def _get_object(self, view): 120 | """ 121 | Obtains parent object by which view queryset should be filtered. 122 | :param view: DRF view instance. 123 | :return: object (instance of init's qs_or_obj model except shadowed by subclass). 124 | """ 125 | queryset = self.get_queryset(view) 126 | queryset = queryset.filter(**{self.parent_field: self.get_value(view)}) 127 | try: 128 | obj = get_object_or_404(queryset) 129 | except Http404: 130 | if self.raise_404: 131 | raise 132 | obj = None 133 | return obj 134 | 135 | def child_queryset(self, queryset, view): 136 | """ 137 | Performs filtering of the view queryset. 138 | :param queryset: view queryset instance. 139 | :param view: view object. 140 | :return: filtered queryset. 141 | """ 142 | obj = self.get_object(view) 143 | if obj is None and not self.allow_none: 144 | return queryset.none() 145 | return queryset.filter(**{self.related_field: obj}) 146 | 147 | 148 | class UrlKwargScopeQuerySet(ScopeQuerySet): 149 | """ 150 | ScopeQuerySet that obtains parent object from url kwargs. 151 | """ 152 | 153 | def __init__(self, *args, **kwargs): 154 | """ 155 | Adds url_kwarg to :class:`rest_easy.views.ScopeQuerySet` init parameters. 156 | 157 | :param args: same as :class:`rest_easy.views.ScopeQuerySet`. 158 | :param url_kwarg: name of url field to be obtained from view's kwargs. By default it will be inferred as 159 | model_name_pk. 160 | :param kwargs: same as :class:`rest_easy.views.ScopeQuerySet`. 161 | """ 162 | self.url_kwarg = kwargs.pop('url_kwarg', None) 163 | super(UrlKwargScopeQuerySet, self).__init__(*args, **kwargs) 164 | if not self.url_kwarg: 165 | try: 166 | self.url_kwarg = '{}_pk'.format(self.queryset.model._meta.model_name) # pylint: disable=protected-access 167 | except AttributeError: 168 | raise RestEasyException('Either related_field or qs_or_obj must be given.') 169 | 170 | def get_value(self, view): 171 | """ 172 | Obtains value from url kwargs. 173 | :param view: DRF view instance. 174 | :return: Value determining parent object. 175 | """ 176 | return view.kwargs.get(self.url_kwarg) 177 | 178 | 179 | class RequestAttrScopeQuerySet(ScopeQuerySet): 180 | """ 181 | ScopeQuerySet that obtains parent object from view's request property. 182 | 183 | It can work two-fold: 184 | 185 | * the request's property contains full object: in this case no filtering of parent's queryset is required. When 186 | using such approach, is_object must be set to True, and qs_or_obj can be None. Chaining will be disabled since it 187 | is inherent to filtering process. 188 | * the request's property contains object's id, uuid, or other unique property. In that case is_object needs to be 189 | explicitly set to False, and qs_or_obj needs to be a Django model or queryset. Chaining will be performed as 190 | usually. 191 | 192 | """ 193 | 194 | def __init__(self, *args, **kwargs): 195 | """ 196 | Adds is_object and request_attr to :class:`rest_easy.views.ScopeQuerySet` init parameters. 197 | 198 | :param args: same as :class:`rest_easy.views.ScopeQuerySet`. 199 | :param request_attr: name of property to be obtained from view.request. 200 | :param is_object: if request's property will be an object or a value to filter by. True by default. 201 | :param kwargs: same as :class:`rest_easy.views.ScopeQuerySet`. 202 | """ 203 | self.request_attr = kwargs.pop('request_attr', None) 204 | if self.request_attr is None: 205 | raise RestEasyException('request_attr must be set explicitly on an {} init.'.format( 206 | self.__class__.__name__)) 207 | self.is_object = kwargs.pop('is_object', True) 208 | super(RequestAttrScopeQuerySet, self).__init__(*args, **kwargs) 209 | 210 | def get_value(self, view): 211 | """ 212 | Obtains value from url kwargs. 213 | :param view: DRF view instance. 214 | :return: Value determining parent object. 215 | """ 216 | return getattr(view.request, self.request_attr, None) 217 | 218 | def _get_object(self, view): 219 | """ 220 | Extends standard _get_object's behaviour with handling values that are already objects. 221 | :param view: DRF view instance. 222 | :return: object to filter view's queryset by. 223 | """ 224 | if self.is_object: 225 | return self.get_value(view) 226 | return super(RequestAttrScopeQuerySet, self)._get_object(view) 227 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # rest_easy documentation build configuration file, created by 4 | # sphinx-quickstart on Tue Oct 27 16:49:54 2015. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | import shlex 18 | from django.conf import settings 19 | import django 20 | 21 | # If extensions (or modules to document with autodoc) are in another directory, 22 | # add these directories to sys.path here. If the directory is relative to the 23 | # documentation root, use os.path.abspath to make it absolute, like shown here. 24 | #sys.path.insert(0, os.path.abspath('.')) 25 | 26 | # -- General configuration ------------------------------------------------ 27 | 28 | # If your documentation needs a minimal Sphinx version, state it here. 29 | #needs_sphinx = '1.0' 30 | 31 | # Add any Sphinx extension module names here, as strings. They can be 32 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 33 | # ones. 34 | extensions = [ 35 | 'sphinx.ext.autodoc', 36 | 'sphinx.ext.intersphinx', 37 | 'sphinx.ext.ifconfig', 38 | 'sphinx.ext.viewcode', 39 | ] 40 | 41 | sys.path.insert(0, os.path.abspath('../')) 42 | 43 | settings.configure(DEBUG_PROPAGATE_EXCEPTIONS=True, 44 | DATABASES={ 45 | 'default': { 46 | 'ENGINE': 'django.db.backends.sqlite3', 47 | 'NAME': ':memory:' 48 | } 49 | }, 50 | SITE_ID=1, 51 | SECRET_KEY='not very secret in tests', 52 | USE_I18N=True, 53 | USE_L10N=True, 54 | STATIC_URL='/static/', 55 | TEMPLATES=[ 56 | { 57 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 58 | 'APP_DIRS': True, 59 | }, 60 | ], 61 | MIDDLEWARE_CLASSES=( 62 | 'django.middleware.common.CommonMiddleware', 63 | 'django.contrib.sessions.middleware.SessionMiddleware', 64 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 65 | 'django.contrib.messages.middleware.MessageMiddleware', 66 | ), 67 | INSTALLED_APPS=( 68 | 'django.contrib.auth', 69 | 'django.contrib.contenttypes', 70 | 'django.contrib.sessions', 71 | 'django.contrib.sites', 72 | 'django.contrib.staticfiles', 73 | 'rest_framework', 74 | 'rest_easy', 75 | 'rest_easy.tests' 76 | ), 77 | PASSWORD_HASHERS=( 78 | 'django.contrib.auth.hashers.MD5PasswordHasher', 79 | )) 80 | django.setup() 81 | 82 | # Add any paths that contain templates here, relative to this directory. 83 | templates_path = ['_templates'] 84 | 85 | # The suffix(es) of source filenames. 86 | # You can specify multiple suffix as a list of string: 87 | # source_suffix = ['.rst', '.md'] 88 | source_suffix = '.rst' 89 | 90 | # The encoding of source files. 91 | #source_encoding = 'utf-8-sig' 92 | 93 | # The master toctree document. 94 | master_doc = 'index' 95 | 96 | # General information about the project. 97 | project = u'rest_easy' 98 | copyright = u'2015, SMARTPAGER SYSTEMS INC.' 99 | author = u'SMARTPAGER SYSTEMS INC.' 100 | 101 | # The version info for the project you're documenting, acts as replacement for 102 | # |version| and |release|, also used in various other places throughout the 103 | # built documents. 104 | # 105 | # The short X.Y version. 106 | version = '0.1' 107 | # The full version, including alpha/beta/rc tags. 108 | release = '0.1' 109 | 110 | # The language for content autogenerated by Sphinx. Refer to documentation 111 | # for a list of supported languages. 112 | # 113 | # This is also used if you do content translation via gettext catalogs. 114 | # Usually you set "language" from the command line for these cases. 115 | language = None 116 | 117 | # There are two options for replacing |today|: either, you set today to some 118 | # non-false value, then it is used: 119 | #today = '' 120 | # Else, today_fmt is used as the format for a strftime call. 121 | #today_fmt = '%B %d, %Y' 122 | 123 | # List of patterns, relative to source directory, that match files and 124 | # directories to ignore when looking for source files. 125 | exclude_patterns = ['_build'] 126 | 127 | # The reST default role (used for this markup: `text`) to use for all 128 | # documents. 129 | #default_role = None 130 | 131 | # If true, '()' will be appended to :func: etc. cross-reference text. 132 | #add_function_parentheses = True 133 | 134 | # If true, the current module name will be prepended to all description 135 | # unit titles (such as .. function::). 136 | #add_module_names = True 137 | 138 | # If true, sectionauthor and moduleauthor directives will be shown in the 139 | # output. They are ignored by default. 140 | #show_authors = False 141 | 142 | # The name of the Pygments (syntax highlighting) style to use. 143 | pygments_style = 'sphinx' 144 | 145 | # A list of ignored prefixes for module index sorting. 146 | #modindex_common_prefix = [] 147 | 148 | # If true, keep warnings as "system message" paragraphs in the built documents. 149 | #keep_warnings = False 150 | 151 | # If true, `todo` and `todoList` produce output, else they produce nothing. 152 | todo_include_todos = False 153 | 154 | 155 | # -- Options for HTML output ---------------------------------------------- 156 | 157 | # The theme to use for HTML and HTML Help pages. See the documentation for 158 | # a list of builtin themes. 159 | html_theme = 'alabaster' 160 | 161 | # Theme options are theme-specific and customize the look and feel of a theme 162 | # further. For a list of options available for each theme, see the 163 | # documentation. 164 | #html_theme_options = {} 165 | 166 | # Add any paths that contain custom themes here, relative to this directory. 167 | #html_theme_path = [] 168 | 169 | # The name for this set of Sphinx documents. If None, it defaults to 170 | # " v documentation". 171 | html_title = 'Django rest easy documentation' 172 | 173 | # A shorter title for the navigation bar. Default is the same as html_title. 174 | #html_short_title = None 175 | 176 | # The name of an image file (relative to this directory) to place at the top 177 | # of the sidebar. 178 | #html_logo = None 179 | 180 | # The name of an image file (within the static path) to use as favicon of the 181 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 182 | # pixels large. 183 | #html_favicon = None 184 | 185 | # Add any paths that contain custom static files (such as style sheets) here, 186 | # relative to this directory. They are copied after the builtin static files, 187 | # so a file named "default.css" will overwrite the builtin "default.css". 188 | html_static_path = ['_static'] 189 | 190 | # Add any extra paths that contain custom files (such as robots.txt or 191 | # .htaccess) here, relative to this directory. These files are copied 192 | # directly to the root of the documentation. 193 | #html_extra_path = [] 194 | 195 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 196 | # using the given strftime format. 197 | #html_last_updated_fmt = '%b %d, %Y' 198 | 199 | # If true, SmartyPants will be used to convert quotes and dashes to 200 | # typographically correct entities. 201 | #html_use_smartypants = True 202 | 203 | # Custom sidebar templates, maps document names to template names. 204 | #html_sidebars = {} 205 | 206 | # Additional templates that should be rendered to pages, maps page names to 207 | # template names. 208 | #html_additional_pages = {} 209 | 210 | # If false, no module index is generated. 211 | #html_domain_indices = True 212 | 213 | # If false, no index is generated. 214 | #html_use_index = True 215 | 216 | # If true, the index is split into individual pages for each letter. 217 | #html_split_index = False 218 | 219 | # If true, links to the reST sources are added to the pages. 220 | #html_show_sourcelink = True 221 | 222 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 223 | #html_show_sphinx = True 224 | 225 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 226 | #html_show_copyright = True 227 | 228 | # If true, an OpenSearch description file will be output, and all pages will 229 | # contain a tag referring to it. The value of this option must be the 230 | # base URL from which the finished HTML is served. 231 | #html_use_opensearch = '' 232 | 233 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 234 | #html_file_suffix = None 235 | 236 | # Language to be used for generating the HTML full-text search index. 237 | # Sphinx supports the following languages: 238 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'hu', 'it', 'ja' 239 | # 'nl', 'no', 'pt', 'ro', 'ru', 'sv', 'tr' 240 | #html_search_language = 'en' 241 | 242 | # A dictionary with options for the search language support, empty by default. 243 | # Now only 'ja' uses this config value 244 | #html_search_options = {'type': 'default'} 245 | 246 | # The name of a javascript file (relative to the configuration directory) that 247 | # implements a search results scorer. If empty, the default will be used. 248 | #html_search_scorer = 'scorer.js' 249 | 250 | # Output file base name for HTML help builder. 251 | htmlhelp_basename = 'rest_easydoc' 252 | 253 | # -- Options for LaTeX output --------------------------------------------- 254 | 255 | latex_elements = { 256 | # The paper size ('letterpaper' or 'a4paper'). 257 | #'papersize': 'letterpaper', 258 | 259 | # The font size ('10pt', '11pt' or '12pt'). 260 | #'pointsize': '10pt', 261 | 262 | # Additional stuff for the LaTeX preamble. 263 | #'preamble': '', 264 | 265 | # Latex figure (float) alignment 266 | #'figure_align': 'htbp', 267 | } 268 | 269 | # Grouping the document tree into LaTeX files. List of tuples 270 | # (source start file, target name, title, 271 | # author, documentclass [howto, manual, or own class]). 272 | latex_documents = [ 273 | (master_doc, 'rest_easy.tex', u'django-rest-easy Documentation', 274 | u'SMARTPAGER SYSTEMS INC.', 'manual'), 275 | ] 276 | 277 | # The name of an image file (relative to this directory) to place at the top of 278 | # the title page. 279 | #latex_logo = None 280 | 281 | # For "manual" documents, if this is true, then toplevel headings are parts, 282 | # not chapters. 283 | #latex_use_parts = False 284 | 285 | # If true, show page references after internal links. 286 | #latex_show_pagerefs = False 287 | 288 | # If true, show URL addresses after external links. 289 | #latex_show_urls = False 290 | 291 | # Documents to append as an appendix to all manuals. 292 | #latex_appendices = [] 293 | 294 | # If false, no module index is generated. 295 | #latex_domain_indices = True 296 | 297 | 298 | # -- Options for manual page output --------------------------------------- 299 | 300 | # One entry per manual page. List of tuples 301 | # (source start file, name, description, authors, manual section). 302 | man_pages = [ 303 | (master_doc, 'rest_easy', u'django-rest-easy Documentation', 304 | [author], 1) 305 | ] 306 | 307 | # If true, show URL addresses after external links. 308 | #man_show_urls = False 309 | 310 | 311 | # -- Options for Texinfo output ------------------------------------------- 312 | 313 | # Grouping the document tree into Texinfo files. List of tuples 314 | # (source start file, target name, title, author, 315 | # dir menu entry, description, category) 316 | texinfo_documents = [ 317 | (master_doc, 'rest_easy', u'django-rest-easy Documentation', 318 | author, 'rest_easy', 'QOL features for DRF views and serializers.', 319 | 'Miscellaneous'), 320 | ] 321 | 322 | # Documents to append as an appendix to all manuals. 323 | #texinfo_appendices = [] 324 | 325 | # If false, no module index is generated. 326 | #texinfo_domain_indices = True 327 | 328 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 329 | #texinfo_show_urls = 'footnote' 330 | 331 | # If true, do not generate a @detailmenu in the "Top" node's menu. 332 | #texinfo_no_detailmenu = False 333 | 334 | 335 | # Example configuration for intersphinx: refer to the Python standard library. 336 | intersphinx_mapping = {'https://docs.python.org/': None} 337 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Profiled execution. 11 | profile=no 12 | 13 | # Add files or directories to the blacklist. They should be base names, not 14 | # paths. 15 | ignore=CVS 16 | 17 | # Pickle collected data for later comparisons. 18 | persistent=yes 19 | 20 | # List of plugins (as comma separated values of python modules names) to load, 21 | # usually to register additional checkers. 22 | load-plugins= 23 | 24 | # Deprecated. It was used to include message's id in output. Use --msg-template 25 | # instead. 26 | include-ids=no 27 | 28 | # Deprecated. It was used to include symbolic ids of messages in output. Use 29 | # --msg-template instead. 30 | symbols=no 31 | 32 | # Use multiple processes to speed up Pylint. 33 | jobs=1 34 | 35 | # Allow loading of arbitrary C extensions. Extensions are imported into the 36 | # active Python interpreter and may run arbitrary code. 37 | unsafe-load-any-extension=no 38 | 39 | # A comma-separated list of package or module names from where C extensions may 40 | # be loaded. Extensions are loading into the active Python interpreter and may 41 | # run arbitrary code 42 | extension-pkg-whitelist= 43 | 44 | # Allow optimization of some AST trees. This will activate a peephole AST 45 | # optimizer, which will apply various small optimizations. For instance, it can 46 | # be used to obtain the result of joining multiple strings with the addition 47 | # operator. Joining a lot of strings can lead to a maximum recursion error in 48 | # Pylint and this flag can prevent that. It has one side effect, the resulting 49 | # AST will be different than the one from reality. 50 | optimize-ast=no 51 | 52 | 53 | [MESSAGES CONTROL] 54 | 55 | # Only show warnings with the listed confidence levels. Leave empty to show 56 | # all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED 57 | confidence= 58 | 59 | # Enable the message, report, category or checker with the given id(s). You can 60 | # either give multiple identifier separated by comma (,) or put this option 61 | # multiple time. See also the "--disable" option for examples. 62 | #enable= 63 | 64 | # Disable the message, report, category or checker with the given id(s). You 65 | # can either give multiple identifiers separated by comma (,) or put this 66 | # option multiple times (only on the command line, not in the configuration 67 | # file where it should appear only once).You can also use "--disable=all" to 68 | # disable everything first and then reenable specific checks. For example, if 69 | # you want to run only the similarities checker, you can use "--disable=all 70 | # --enable=similarities". If you want to run only the classes checker, but have 71 | # no Warning level messages displayed, use"--disable=all --enable=classes 72 | # --disable=W" 73 | disable=E1608,W1627,E1601,E1603,E1602,E1605,E1604,E1607,E1606,W1621,W1620,W1623,W1622,W1625,W1624,W1609,W1608,W1607,W1606,W1605,W1604,W1603,W1602,W1601,W1639,W1640,I0021,W1638,I0020,W1618,W1619,W1630,W1626,W1637,W1634,W1635,W1610,W1611,W1612,W1613,W1614,W1615,W1616,W1617,W1632,W1633,W0704,W1628,W1629,W1636,R0901 74 | 75 | 76 | [REPORTS] 77 | 78 | # Set the output format. Available formats are text, parseable, colorized, msvs 79 | # (visual studio) and html. You can also give a reporter class, eg 80 | # mypackage.mymodule.MyReporterClass. 81 | output-format=text 82 | 83 | # Put messages in a separate file for each module / package specified on the 84 | # command line instead of printing them on stdout. Reports (if any) will be 85 | # written in a file name "pylint_global.[txt|html]". 86 | files-output=no 87 | 88 | # Tells whether to display a full report or only the messages 89 | reports=yes 90 | 91 | # Python expression which should return a note less than 10 (10 is the highest 92 | # note). You have access to the variables errors warning, statement which 93 | # respectively contain the number of errors / warnings messages and the total 94 | # number of statements analyzed. This is used by the global evaluation report 95 | # (RP0004). 96 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 97 | 98 | # Add a comment according to your evaluation note. This is used by the global 99 | # evaluation report (RP0004). 100 | comment=no 101 | 102 | # Template used to display messages. This is a python new-style format string 103 | # used to format the message information. See doc for all details 104 | #msg-template= 105 | 106 | 107 | [SPELLING] 108 | 109 | # Spelling dictionary name. Available dictionaries: none. To make it working 110 | # install python-enchant package. 111 | spelling-dict= 112 | 113 | # List of comma separated words that should not be checked. 114 | spelling-ignore-words= 115 | 116 | # A path to a file that contains private dictionary; one word per line. 117 | spelling-private-dict-file= 118 | 119 | # Tells whether to store unknown words to indicated private dictionary in 120 | # --spelling-private-dict-file option instead of raising a message. 121 | spelling-store-unknown-words=no 122 | 123 | 124 | [SIMILARITIES] 125 | 126 | # Minimum lines number of a similarity. 127 | min-similarity-lines=4 128 | 129 | # Ignore comments when computing similarities. 130 | ignore-comments=yes 131 | 132 | # Ignore docstrings when computing similarities. 133 | ignore-docstrings=yes 134 | 135 | # Ignore imports when computing similarities. 136 | ignore-imports=no 137 | 138 | 139 | [VARIABLES] 140 | 141 | # Tells whether we should check for unused import in __init__ files. 142 | init-import=no 143 | 144 | # A regular expression matching the name of dummy variables (i.e. expectedly 145 | # not used). 146 | dummy-variables-rgx=_$|dummy 147 | 148 | # List of additional names supposed to be defined in builtins. Remember that 149 | # you should avoid to define new builtins when possible. 150 | additional-builtins= 151 | 152 | # List of strings which can identify a callback function by name. A callback 153 | # name must start or end with one of those strings. 154 | callbacks=cb_,_cb 155 | 156 | 157 | [LOGGING] 158 | 159 | # Logging modules to check that the string format arguments are in logging 160 | # function parameter format 161 | logging-modules=logging 162 | 163 | 164 | [BASIC] 165 | 166 | # Required attributes for module, separated by a comma 167 | required-attributes= 168 | 169 | # List of builtins function names that should not be used, separated by a comma 170 | bad-functions=map,filter,input 171 | 172 | # Good variable names which should always be accepted, separated by a comma 173 | good-names=i,j,k,ex,Run,_ 174 | 175 | # Bad variable names which should always be refused, separated by a comma 176 | bad-names=foo,bar,baz,toto,tutu,tata 177 | 178 | # Colon-delimited sets of names that determine each other's naming style when 179 | # the name regexes allow several styles. 180 | name-group= 181 | 182 | # Include a hint for the correct naming format with invalid-name 183 | include-naming-hint=no 184 | 185 | # Regular expression matching correct function names 186 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 187 | 188 | # Naming hint for function names 189 | function-name-hint=[a-z_][a-z0-9_]{2,30}$ 190 | 191 | # Regular expression matching correct variable names 192 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 193 | 194 | # Naming hint for variable names 195 | variable-name-hint=[a-z_][a-z0-9_]{2,30}$ 196 | 197 | # Regular expression matching correct constant names 198 | const-rgx=(([a-zA-Z_][a-zA-Z0-9_]*)|(__.*__))$ 199 | 200 | # Naming hint for constant names 201 | const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 202 | 203 | # Regular expression matching correct attribute names 204 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 205 | 206 | # Naming hint for attribute names 207 | attr-name-hint=[a-z_][a-z0-9_]{2,30}$ 208 | 209 | # Regular expression matching correct argument names 210 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 211 | 212 | # Naming hint for argument names 213 | argument-name-hint=[a-z_][a-z0-9_]{2,30}$ 214 | 215 | # Regular expression matching correct class attribute names 216 | class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 217 | 218 | # Naming hint for class attribute names 219 | class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ 220 | 221 | # Regular expression matching correct inline iteration names 222 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 223 | 224 | # Naming hint for inline iteration names 225 | inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ 226 | 227 | # Regular expression matching correct class names 228 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 229 | 230 | # Naming hint for class names 231 | class-name-hint=[A-Z_][a-zA-Z0-9]+$ 232 | 233 | # Regular expression matching correct module names 234 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 235 | 236 | # Naming hint for module names 237 | module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 238 | 239 | # Regular expression matching correct method names 240 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 241 | 242 | # Naming hint for method names 243 | method-name-hint=[a-z_][a-z0-9_]{2,30}$ 244 | 245 | # Regular expression which should only match function or class names that do 246 | # not require a docstring. 247 | no-docstring-rgx=__.*__ 248 | 249 | # Minimum line length for functions/classes that require docstrings, shorter 250 | # ones are exempt. 251 | docstring-min-length=-1 252 | 253 | 254 | [FORMAT] 255 | 256 | # Maximum number of characters on a single line. 257 | max-line-length=119 258 | 259 | # Regexp for a line that is allowed to be longer than the limit. 260 | ignore-long-lines=^\s*(# )??$ 261 | 262 | # Allow the body of an if to be on the same line as the test if there is no 263 | # else. 264 | single-line-if-stmt=no 265 | 266 | # List of optional constructs for which whitespace checking is disabled 267 | no-space-check=trailing-comma,dict-separator 268 | 269 | # Maximum number of lines in a module 270 | max-module-lines=1000 271 | 272 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 273 | # tab). 274 | indent-string=' ' 275 | 276 | # Number of spaces of indent required inside a hanging or continued line. 277 | indent-after-paren=4 278 | 279 | # Expected format of line ending, e.g. empty (any line ending), LF or CRLF. 280 | expected-line-ending-format= 281 | 282 | 283 | [MISCELLANEOUS] 284 | 285 | # List of note tags to take in consideration, separated by a comma. 286 | notes=FIXME,XXX,TODO 287 | 288 | 289 | [TYPECHECK] 290 | 291 | # Tells whether missing members accessed in mixin class should be ignored. A 292 | # mixin class is detected if its name ends with "mixin" (case insensitive). 293 | ignore-mixin-members=yes 294 | 295 | # List of module names for which member attributes should not be checked 296 | # (useful for modules/projects where namespaces are manipulated during runtime 297 | # and thus existing member attributes cannot be deduced by static analysis 298 | ignored-modules= 299 | 300 | # List of classes names for which member attributes should not be checked 301 | # (useful for classes with attributes dynamically set). 302 | ignored-classes=SQLObject,TestAPI 303 | 304 | # When zope mode is activated, add a predefined set of Zope acquired attributes 305 | # to generated-members. 306 | zope=no 307 | 308 | # List of members which are set dynamically and missed by pylint inference 309 | # system, and so shouldn't trigger E0201 when accessed. Python regular 310 | # expressions are accepted. 311 | generated-members=REQUEST,acl_users,aq_parent 312 | 313 | 314 | [CLASSES] 315 | 316 | # List of interface methods to ignore, separated by a comma. This is used for 317 | # instance to not check methods defines in Zope's Interface base class. 318 | ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by 319 | 320 | # List of method names used to declare (i.e. assign) instance attributes. 321 | defining-attr-methods=__init__,__new__,setUp 322 | 323 | # List of valid names for the first argument in a class method. 324 | valid-classmethod-first-arg=cls 325 | 326 | # List of valid names for the first argument in a metaclass class method. 327 | valid-metaclass-classmethod-first-arg=mcs 328 | 329 | # List of member names, which should be excluded from the protected access 330 | # warning. 331 | exclude-protected=_asdict,_fields,_replace,_source,_make 332 | 333 | 334 | [DESIGN] 335 | 336 | # Maximum number of arguments for function / method 337 | max-args=10 338 | 339 | # Argument names that match this expression will be ignored. Default to name 340 | # with leading underscore 341 | ignored-argument-names=_.* 342 | 343 | # Maximum number of locals for function / method body 344 | max-locals=15 345 | 346 | # Maximum number of return / yield for function / method body 347 | max-returns=6 348 | 349 | # Maximum number of branch for function / method body 350 | max-branches=12 351 | 352 | # Maximum number of statements in function / method body 353 | max-statements=50 354 | 355 | # Maximum number of parents for a class (see R0901). 356 | max-parents=7 357 | 358 | # Maximum number of attributes for a class (see R0902). 359 | max-attributes=7 360 | 361 | # Minimum number of public methods for a class (see R0903). 362 | min-public-methods=1 363 | 364 | # Maximum number of public methods for a class (see R0904). 365 | max-public-methods=20 366 | 367 | 368 | [IMPORTS] 369 | 370 | # Deprecated modules which should not be used, separated by a comma 371 | deprecated-modules=regsub,TERMIOS,Bastion,rexec 372 | 373 | # Create a graph of every (i.e. internal and external) dependencies in the 374 | # given file (report RP0402 must not be disabled) 375 | import-graph= 376 | 377 | # Create a graph of external dependencies in the given file (report RP0402 must 378 | # not be disabled) 379 | ext-import-graph= 380 | 381 | # Create a graph of internal dependencies in the given file (report RP0402 must 382 | # not be disabled) 383 | int-import-graph= 384 | 385 | 386 | [EXCEPTIONS] 387 | 388 | # Exceptions that will emit a warning when being caught. Defaults to 389 | # "Exception" 390 | overgeneral-exceptions=Exception 391 | -------------------------------------------------------------------------------- /rest_easy/views.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # pylint: disable=too-few-public-methods 3 | """ 4 | This module provides redefined DRF's generic views and viewsets leveraging serializer registration. 5 | 6 | One of the main issues with creating traditional DRF APIs is a lot of bloat (and we're writing Python, not Java or C#, 7 | to avoid bloat) that's completely unnecessary in a structured Django project. Therefore, this module aims to provide 8 | a better and simpler way to write simple API endpoints - without limiting the ability to create more complex views. 9 | The particular means to that end are: 10 | 11 | * :class:`rest_easy.scopes.ScopeQuerySet` and its subclasses (:class:`rest_easy.scopes.UrlKwargScopeQuerySet` and 12 | :class:`rest_easy.scopes.RequestAttrScopeQuerySet`) provide a simple way to scope views and viewsets. 13 | by resource (ie. limiting results to single account, or /resource//inner_resource//) 14 | * generic views leveraging the above, as well as model-and-schema specification instead of queryset, serializer and 15 | helper methods - all generic views that were available in DRF as well as GenericAPIView are redefined to support 16 | this. 17 | * Generic :class:`rest_easy.views.ModelViewSet` which allows for very simple definition of resource 18 | endpoint. 19 | 20 | To make the new views work, all that\'s required is a serializer:: 21 | 22 | from users.models import User 23 | from accounts.models import Account 24 | from rest_easy.serializers import ModelSerializer 25 | class UserSerializer(ModelSerializer): 26 | class Meta: 27 | model = User 28 | fields = '__all__' 29 | schema = 'default' 30 | 31 | class UserViewSet(ModelViewSet): 32 | model = User 33 | scope = UrlKwargScopeQuerySet(Account) 34 | 35 | and in urls.py:: 36 | 37 | from django.conf.urls import url, include 38 | from rest_framework.routers import DefaultRouter 39 | router = DefaultRouter() 40 | router.register(r'accounts/(?P[0-9]+)/users', UserViewSet) 41 | urlpatterns = [url(r'^', include(router.urls))] 42 | 43 | The above will provide the users scoped by account primary key as resources: with list, retrieve, create, update and 44 | partial update methods, as well as standard HEAD and OPTIONS autogenerated responses. 45 | 46 | You can easily add custom paths to viewsets when needed - it's described in DRF documentation. 47 | """ 48 | 49 | from django.conf import settings 50 | from rest_framework.viewsets import ViewSetMixin 51 | from rest_framework import generics, mixins 52 | from six import with_metaclass 53 | 54 | from rest_easy.exceptions import RestEasyException 55 | from rest_easy.registers import serializer_register 56 | from rest_easy.scopes import ScopeQuerySet 57 | 58 | __all__ = ['GenericAPIView', 'CreateAPIView', 'ListAPIView', 'RetrieveAPIView', 'DestroyAPIView', 'UpdateAPIView', 59 | 'ListCreateAPIView', 'RetrieveUpdateAPIView', 'RetrieveDestroyAPIView', 'RetrieveUpdateDestroyAPIView', 60 | 'ReadOnlyModelViewSet', 'ModelViewSet'] 61 | 62 | 63 | def get_additional_bases(): 64 | """ 65 | Looks for additional view bases in settings.REST_EASY_VIEW_BASES. 66 | :return: 67 | """ 68 | resolved_bases = [] 69 | from importlib import import_module 70 | for base in getattr(settings, 'REST_EASY_VIEW_BASES', []): 71 | mod, cls = base.rsplit('.', 1) 72 | resolved_bases.append(getattr(import_module(mod), cls)) 73 | 74 | return resolved_bases 75 | 76 | 77 | def get_additional_mixins(): 78 | """ 79 | Looks for additional view bases in settings.REST_EASY_VIEW_MIXINS. 80 | :return: 81 | """ 82 | resolved_bases = [] 83 | from importlib import import_module 84 | for base in getattr(settings, 'REST_EASY_GENERIC_VIEW_MIXINS', []): 85 | mod, cls = base.rsplit('.', 1) 86 | resolved_bases.append(getattr(import_module(mod), cls)) 87 | 88 | return resolved_bases 89 | 90 | ADDITIONAL_MIXINS = get_additional_mixins() 91 | 92 | 93 | class ScopedViewMixin(object): 94 | """ 95 | This class provides a get_queryset method that works with ScopeQuerySet. 96 | 97 | Queryset obtained from superclass is filtered by view.scope's (if it exists) child_queryset() method. 98 | """ 99 | 100 | def get_queryset(self): 101 | """ 102 | Calls scope's child_queryset methods on queryset as obtained from superclass. 103 | :return: queryset. 104 | """ 105 | queryset = super(ScopedViewMixin, self).get_queryset() 106 | if hasattr(self, 'scope') and self.scope: 107 | for scope in self.scope: 108 | queryset = scope.child_queryset(queryset, self) 109 | return queryset 110 | 111 | def get_scoped_object(self, handle): 112 | """ 113 | Obtains object from scope when scope's get_object_handle was set. 114 | :param handle: get_object_handle used in scope initialization. 115 | :return: object used by scope to filter. 116 | """ 117 | scope = self.rest_easy_available_object_handles.get(handle, None) 118 | if scope: 119 | return scope.get_object(self) 120 | raise AttributeError('{} get_object handle not found on object {}'.format(handle, self)) 121 | 122 | def __getattr__(self, item): 123 | """ 124 | A shortcut providing get_{get_object_handle} to be able to easily access objects used by this view's scopes 125 | for filtering. For example, scope = UrlKwargScopeQuerySet(Account) will be available with self.get_account(). 126 | :param item: item to obtain plus 'get_' prefix 127 | :return: object used by scope for filtering. 128 | """ 129 | if not item.startswith('get_'): 130 | raise AttributeError('{} not found on object {}'.format(item, self)) 131 | handle = item[4:] 132 | try: 133 | return self.get_scoped_object(handle) 134 | except AttributeError: 135 | raise AttributeError('{} not found on object {}'.format(item, self)) 136 | 137 | 138 | class ViewEasyMetaclass(type): # pylint: disable=too-few-public-methods 139 | """ 140 | This metaclass sets default queryset on a model-and-schema based views and fills in concrete views with bases. 141 | 142 | It's required for compatibility with some of DRF's elements, like routers. 143 | """ 144 | 145 | def __new__(mcs, name, bases, attrs): 146 | """ 147 | Create the class. 148 | """ 149 | if ('queryset' not in attrs or attrs['queryset'] is None) and 'model' in attrs: 150 | attrs['queryset'] = attrs['model'].objects.all() 151 | if 'scope' in attrs and isinstance(attrs['scope'], ScopeQuerySet): 152 | attrs['scope'] = [attrs['scope']] 153 | attrs['rest_easy_available_object_handles'] = {} 154 | cls = super(ViewEasyMetaclass, mcs).__new__(mcs, name, bases, attrs) 155 | for scope in getattr(cls, 'scope', []): 156 | scope.contribute_to_class(cls) 157 | return cls 158 | 159 | 160 | class ChainingCreateUpdateMixin(object): 161 | """ 162 | Chain-enabled versions of perform_create and perform_update. 163 | """ 164 | 165 | def perform_create(self, serializer, **kwargs): # pylint: disable=no-self-use 166 | """ 167 | Extend default implementation with kwarg chaining. 168 | """ 169 | return serializer.save(**kwargs) 170 | 171 | def perform_update(self, serializer, **kwargs): # pylint: disable=no-self-use 172 | """ 173 | Extend default implementation with kwarg chaining. 174 | """ 175 | return serializer.save(**kwargs) 176 | 177 | 178 | class GenericAPIViewBase(ScopedViewMixin, generics.GenericAPIView): 179 | """ 180 | Provides a base for all generic views and viewsets leveraging registered serializers and ScopeQuerySets. 181 | 182 | Adds additional DRF-verb-wise override for obtaining serializer class: serializer_schema_for_verb property. 183 | It should be a dictionary of DRF verbs and serializer schemas (they work in conjunction with model property). 184 | serializer_schema_for_verb = {'update': 'schema-mutate', 'create': 'schema-mutate'} 185 | The priority for obtaining serializer class is: 186 | 187 | * get_serializer_class override 188 | * serializer_class property 189 | * model + serializer_schema_for_verb[verb] lookup in :class:`rest_easy.registers.SerializerRegister` 190 | * model + schema lookup in :class:`rest_easy.registers.SerializerRegister` 191 | 192 | """ 193 | serializer_schema_for_verb = {} 194 | 195 | def __init__(self, **kwargs): 196 | """ 197 | Set object cache to empty dict. 198 | :param kwargs: Passthrough to Django view. 199 | """ 200 | super(GenericAPIViewBase, self).__init__(**kwargs) 201 | self.rest_easy_object_cache = {} 202 | 203 | def get_drf_verb(self): 204 | """ 205 | Obtain the DRF verb used for a request. 206 | """ 207 | method = self.request.method.lower() 208 | if method == 'get': 209 | if self.lookup_url_kwarg in self.kwargs: 210 | return 'retrieve' 211 | mapping = { 212 | 'get': 'list', 213 | 'post': 'create', 214 | 'put': 'update', 215 | 'patch': 'partial_update', 216 | 'delete': 'destroy' 217 | } 218 | return mapping[method] 219 | 220 | def get_serializer_name(self, verb=None): 221 | """ 222 | Obtains registered serializer name for this view. 223 | 224 | Leverages :class:`rest_easy.registers.SerializerRegister`. Works when either of or both model 225 | and schema properties are available on this view. 226 | 227 | :return: registered serializer key. 228 | """ 229 | model = getattr(self, 'model', None) 230 | schema = None 231 | if not model and not hasattr(self, 'schema') and (verb and verb not in self.serializer_schema_for_verb): 232 | raise RestEasyException('Either model or schema fields need to be set on a model-based GenericAPIView.') 233 | if verb: 234 | schema = self.serializer_schema_for_verb.get(verb, None) 235 | if schema is None: 236 | schema = getattr(self, 'schema', 'default') 237 | return serializer_register.get_name(model, schema) 238 | 239 | def get_serializer_class(self): 240 | """ 241 | Gets serializer appropriate for this view. 242 | 243 | Leverages :class:`rest_easy.registers.SerializerRegister`. Works when either of or both model 244 | and schema properties are available on this view. 245 | 246 | :return: serializer class. 247 | """ 248 | 249 | if hasattr(self, 'serializer_class') and self.serializer_class: 250 | return self.serializer_class 251 | 252 | serializer = serializer_register.lookup(self.get_serializer_name(verb=self.get_drf_verb())) 253 | if serializer: 254 | return serializer 255 | 256 | raise RestEasyException(u'Serializer for model {} and schema {} cannot be found.'.format( 257 | getattr(self, 'model', '[no model]'), 258 | getattr(self, 'schema', '[no schema]') 259 | )) 260 | 261 | 262 | class GenericAPIView(with_metaclass(ViewEasyMetaclass, *(get_additional_bases() + [GenericAPIViewBase]))): 263 | """ 264 | Base view with compat metaclass. 265 | """ 266 | __abstract__ = True 267 | 268 | 269 | def create(self, request, *args, **kwargs): # pragma: no cover 270 | """ 271 | Shortcut method. 272 | """ 273 | return self.create(request, *args, **kwargs) 274 | 275 | 276 | def list_(self, request, *args, **kwargs): # pragma: no cover 277 | """ 278 | Shortcut method. 279 | """ 280 | return self.list(request, *args, **kwargs) 281 | 282 | 283 | def retrieve(self, request, *args, **kwargs): # pragma: no cover 284 | """ 285 | Shortcut method. 286 | """ 287 | return self.retrieve(request, *args, **kwargs) 288 | 289 | 290 | def destroy(self, request, *args, **kwargs): # pragma: no cover 291 | """ 292 | Shortcut method. 293 | """ 294 | return self.destroy(request, *args, **kwargs) 295 | 296 | 297 | def update(self, request, *args, **kwargs): # pragma: no cover 298 | """ 299 | Shortcut method. 300 | """ 301 | return self.update(request, *args, **kwargs) 302 | 303 | 304 | def partial_update(self, request, *args, **kwargs): # pragma: no cover 305 | """ 306 | Shortcut method. 307 | """ 308 | return self.partial_update(request, *args, **kwargs) 309 | 310 | 311 | CreateAPIView = type('CreateAPIView', 312 | tuple(ADDITIONAL_MIXINS + [ChainingCreateUpdateMixin, mixins.CreateModelMixin, GenericAPIView]), 313 | {'post': create, 314 | '__doc__': "Concrete view for retrieving or deleting a model instance."}) 315 | 316 | ListAPIView = type('ListAPIView', 317 | tuple(ADDITIONAL_MIXINS + [mixins.ListModelMixin, GenericAPIView]), 318 | {'get': list_, 319 | '__doc__': "Concrete view for listing a queryset."}) 320 | 321 | 322 | RetrieveAPIView = type('RetrieveAPIView', 323 | tuple(ADDITIONAL_MIXINS + [mixins.RetrieveModelMixin, GenericAPIView]), 324 | {'get': retrieve, 325 | '__doc__': "Concrete view for retrieving a model instance."}) 326 | 327 | 328 | DestroyAPIView = type('DestroyAPIView', 329 | tuple(ADDITIONAL_MIXINS + [mixins.DestroyModelMixin, GenericAPIView]), 330 | {'delete': destroy, 331 | '__doc__': "Concrete view for deleting a model instance."}) 332 | 333 | 334 | UpdateAPIView = type('UpdateAPIView', 335 | tuple(ADDITIONAL_MIXINS + [ChainingCreateUpdateMixin, mixins.UpdateModelMixin, GenericAPIView]), 336 | {'put': update, 337 | 'patch': partial_update, 338 | '__doc__': "Concrete view for updating a model instance."}) 339 | 340 | 341 | ListCreateAPIView = type('ListCreateAPIView', 342 | tuple(ADDITIONAL_MIXINS + 343 | [ChainingCreateUpdateMixin, mixins.ListModelMixin, 344 | mixins.CreateModelMixin, GenericAPIView]), 345 | {'get': list_, 346 | 'post': create, 347 | '__doc__': "Concrete view for listing a queryset or creating a model instance."}) 348 | 349 | 350 | RetrieveUpdateAPIView = type('RetrieveUpdateAPIView', 351 | tuple(ADDITIONAL_MIXINS + 352 | [ChainingCreateUpdateMixin, mixins.RetrieveModelMixin, 353 | mixins.UpdateModelMixin, GenericAPIView]), 354 | {'get': retrieve, 355 | 'put': update, 356 | 'patch': partial_update, 357 | '__doc__': "Concrete view for retrieving, updating a model instance."}) 358 | 359 | 360 | RetrieveDestroyAPIView = type('RetrieveDestroyAPIView', 361 | tuple(ADDITIONAL_MIXINS + 362 | [mixins.RetrieveModelMixin, mixins.DestroyModelMixin, GenericAPIView]), 363 | {'get': retrieve, 364 | 'delete': destroy, 365 | '__doc__': "Concrete view for retrieving or deleting a model instance."}) 366 | 367 | 368 | RetrieveUpdateDestroyAPIView = type('RetrieveUpdateDestroyAPIView', 369 | tuple(ADDITIONAL_MIXINS + 370 | [ChainingCreateUpdateMixin, mixins.RetrieveModelMixin, 371 | mixins.UpdateModelMixin, mixins.DestroyModelMixin, GenericAPIView]), 372 | {'get': retrieve, 373 | 'put': update, 374 | 'patch': partial_update, 375 | 'delete': destroy, 376 | '__doc__': "Concrete view for retrieving, updating or deleting a model instance." 377 | }) 378 | 379 | 380 | class GenericViewSet(ViewSetMixin, GenericAPIView): # pragma: no cover 381 | """ 382 | The GenericViewSet class does not provide any actions by default, 383 | but does include the base set of generic view behavior, such as 384 | the `get_object` and `get_queryset` methods. 385 | """ 386 | pass 387 | 388 | 389 | ReadOnlyModelViewSet = type('ReadOnlyModelViewSet', 390 | tuple(ADDITIONAL_MIXINS + 391 | [mixins.RetrieveModelMixin, mixins.ListModelMixin, GenericViewSet]), 392 | {'__doc__': "A viewset that provides default `list()` and `retrieve()` actions."}) 393 | 394 | 395 | ModelViewSet = type('ModelViewSet', 396 | tuple(ADDITIONAL_MIXINS + 397 | [ChainingCreateUpdateMixin, mixins.CreateModelMixin, mixins.RetrieveModelMixin, 398 | mixins.UpdateModelMixin, mixins.DestroyModelMixin, mixins.ListModelMixin, GenericViewSet]), 399 | {'__doc__': "A viewset that provides default `create()`, `retrieve()`, `update()`, " 400 | "`partial_update()`, `destroy()` and `list()` actions."}) 401 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | ************ 2 | Introduction 3 | ************ 4 | 5 | Django-rest-easy is an extension to Django Rest Framework providing QOL improvements to serializers and views that introduce a more 6 | coherent workflow for creating REST APIs: 7 | 8 | * Versioning and referencing serializers by model and schema, along with autoimport, so your serializers will be available anywhere, 9 | as long as you know the model and schema. 10 | * A :class:`rest_easy.fields.StaticField` for adding static data (independent of instance) to serializers. 11 | * Creating views and viewsets using model and schema (it will automatically obtain serializer and queryset, although you can override 12 | both with usual DRF class-level parameters). 13 | * A serializer override for a particular DRF verb, like create or update: no manual get_serialize_class override, no splitting ViewSets 14 | into multiple views. 15 | * Scoping views\' querysets and viewsets by url kwargs or request object parameters. Fore example, when you want to limit messages to 16 | a particular thread or threads to currently logged in user. 17 | * Adding your own base classes to `GenericView` and your own mixins to all resulting generic view classes, like `ListCreateAPIView`. 18 | * Chaining views\' `perform_update` and `perform_create`: they by default pass \*\*kwargs to `serializer.save()` now. 19 | * A helper mixin that enables serializing Django model instances with just an instance method call. 20 | * A helper methods that find serializer class and deserialize a blob of data, since oftentimes you will not know what exact data you will 21 | receive in a particular endpoint, especially when dealing with complex integrations. 22 | 23 | All of the above are possible in pure DRF, but usually introduce a lot of boilerplate or aren\'t very easy or straightforward to code 24 | Therefore, at Telmediq we decided to open source the package that helps make our API code cleaner and more concise. 25 | 26 | ************ 27 | Installation 28 | ************ 29 | 30 | Django-rest-easy is available on PyPI. The simplest way to install it is by running `pip install django-rest-easy`. Afterwards you need 31 | to add rest_easy to Django's `INSTALLED_APPS`:: 32 | 33 | INSTALLED_APPS = ( 34 | # ... 35 | 'rest_framework', 36 | 'rest_easy', 37 | # ... 38 | ) 39 | 40 | To make your serializers registered and working well with django-rest-easy\'s views, make sure they are autoimported. You can do that 41 | either by importing them in `app.serializers` module or modifying `REST_EASY_AUTOIMPORT_SERIALIZERS_FROM` setting to include your 42 | serializer location. For example, if you place your serializers in `app.api.serializers`, you should add the following to your settings 43 | file:: 44 | 45 | REST_EASY_AUTOIMPORT_SERIALIZERS_FROM = ['api.serializers'] 46 | 47 | Also, change your serializers to inherit from :class:`rest_easy.serializers.Serializer` or :class:`rest_easy.serializers.ModelSerializer` 48 | instead of default DRF serializers. Same goes for views - you should be using this:: 49 | 50 | from rest_easy.views import * 51 | 52 | Instead of :: 53 | 54 | from rest_framework.generics import * 55 | 56 | Additionally, the following settings can alter the behaviour of the package: 57 | 58 | * REST_EASY_AUTOIMPORT_SERIALIZERS_FROM - specify modules or packages that rest-easy will try to import serializers 59 | from when AppConfig is ready. The import is app-based, so it will search for serializers in all installed apps. 60 | By default `['serializers']` 61 | * REST_EASY_VIEW_BASES - the mixins that should go into all views near the end of the mro (method resolution order). They 62 | will be placed before all DRF and django-rest-easy's bases, and after all generic mixins from DRF. 63 | * REST_EASY_GENERIC_VIEW_MIXINS - the mixins that should go into all generic views at the beginning of the mro 64 | (that means CreateAPIView, ListAPIView, RetrieveAPIView, DestroyAPIView, UpdateAPIView, ListCreateAPIView, 65 | RetrieveUpdateAPIView, RetrieveDestroyAPIView, RetrieveUpdateDestroyAPIView, ReadOnlyModelViewSet, 66 | ModelViewSet). 67 | * REST_EASY_SERIALIZER_CONFLICT_POLICY - either 'allow' or 'raise'. What should happen when you redeclare a serializer 68 | with same model and schema - either the new one will be used or an error will be raised. By default 'allow' to not 69 | break applications with weird imports. 70 | 71 | Because you usually won't be able to import the bases directly in settings, they should be given using class location strings (as is 72 | often the case in Django):: 73 | 74 | REST_EASY_VIEW_BASES = ['myapp.mixins.GlobalBase'] 75 | REST_EASY_GENERIC_VIEW_MIXINS = ['myapp.mixins.SuperMixin', 'myotherapp.mixins.WhatIsItMixin'] 76 | 77 | They will be prepended to base class lists preserving their order. Please make sure that you are not importing django-rest-easy views 78 | before the mixins are ready to import (so before `AppConfig.ready` is called, for good measure). 79 | 80 | *********** 81 | Basic usage 82 | *********** 83 | 84 | A minimal example to showcase what you can do would be:: 85 | 86 | from django.conf.urls import include, url 87 | from rest_framework.routers import DefaultRouter 88 | 89 | from rest_easy.serializers import ModelSerializer 90 | from rest_easy.views import ModelViewSet 91 | from rest_easy.scopes import UrlKwargScopeQuerySet 92 | from rest_easy.tests.models import Account, User 93 | 94 | class UserSerializer(ModelSerializer): 95 | class Meta: 96 | model = User 97 | schema = 'default' 98 | fields = '__all__' 99 | 100 | class UserViewSet(ModelViewSet): 101 | model = User 102 | schema = 'default' 103 | lookup_url_kwarg = 'pk' 104 | scope = UrlKwargScopeQuerySet(Account) 105 | 106 | router = DefaultRouter() 107 | router.register(r'accounts/(?P\d+)/users', UserViewSet) 108 | 109 | urlpatterns = [url(r'^', include(router.urls))] 110 | 111 | ************** 112 | Detailed usage 113 | ************** 114 | 115 | Serializers 116 | =========== 117 | 118 | Django-rest-easy serializer bases (:class:`rest_easy.serializers.Serializer` and :class:`rest_easy.serializers.ModelSerializer`) are 119 | registered on creation and provide some consistency constraints: each serializer needs to have model and schema set in its Meta. Schema 120 | needs to be a string, while model should be a Django model subclass or explicit `None`. Both of those properties are required to be able 121 | to register the serializer properly. Both are also appended to serializer's fields as :class:`rest_easy.fields.StaticField`. They will 122 | be auto-included in `Meta.fields` when necessary (ie. fields is not `__all__`):: 123 | 124 | class UserSerializer(ModelSerializer): 125 | class Meta: 126 | model = User 127 | schema = 'default' 128 | fields = '__all__' 129 | 130 | Serializers can be obtained easily from :class:`rest_easy.registers.SerializerRegister` (or, already instantiated, 131 | `rest_easy.registers.serializer_register`) like so:: 132 | 133 | from rest_easy.registers import serializer_register 134 | 135 | serializer = serializer_register.get('myapp.mymodel', 'default-schema') 136 | # or 137 | from myapp.models import MyModel 138 | serializer = serializer_register.get(MyModel, 'default-schema') 139 | # or 140 | serializer = serializer_register.get(None, 'modelless-schema') 141 | 142 | This feature is leveraged heavily by django-rest-easy's views. Please remember that serializers need to be imported in order to be 143 | registered - it's best achieved by using the auto-import functionality described in the installation section. 144 | 145 | As for the :class:`rest_easy.fields.StaticField`, it can be used as such:: 146 | 147 | class UserSerializer(ModelSerializer): 148 | class Meta: 149 | model = User 150 | schema = 'default' 151 | fields = '__all__' 152 | static_data = StaticField(value='static_value') 153 | 154 | Views 155 | ===== 156 | 157 | Views and viewsets provide a few additional features, allowing you to not specify `queryset` and `serializer_class` properties by 158 | default. If they are specified, though, they take priority over any logic provided by django-rest-easy. 159 | 160 | * Providing `serializer_class` will disable per-verb custom serializers. It will make the view act basically as regular DRF view. 161 | * `queryset` property doesn't disable any functionality. By default it is set to `model.objects.all()`, where model is provided as a 162 | class property, but it can be overridden at will without messing with django-rest-easy's functionality. 163 | 164 | Overall using serializer_class on django-rest-easy views is not recommended. 165 | 166 | A view example showing available features:: 167 | 168 | class UserViewSet(ModelViewSet): 169 | model = User 170 | schema = 'default' 171 | serializer_schema_for_verb = {'update': 'schema-mutate', 'create': 'schema-mutate'} 172 | lookup_url_kwarg = 'pk' 173 | scope = UrlKwargScopeQuerySet(Account) 174 | 175 | def perform_update(self, serializer, **kwargs): 176 | kwargs['account'] = self.get_account() 177 | return super(UserViewSet, self).perform_update(serializer, **kwargs) 178 | 179 | def perform_create(self, serializer, **kwargs): 180 | kwargs['account'] = self.get_account() 181 | return super(UserViewSet, self).perform_create(serializer, **kwargs) 182 | 183 | We're setting `User` as model, so the inferred queryest will be `User.objects.all()`. When a request comes in, a proper serializer will 184 | be selected: 185 | 186 | * If the DRF dispatcher will call update or create methods, we will use serializer obtained by calling 187 | `serializer_register.get(User, 'schema-mutate')`. 188 | * Otherwise the default schema will be used, so `serializer_register.get(User, 'default')`. 189 | 190 | Additionally we're scoping the Users by account. In short, that means (by default - more on that in the section below) that our base 191 | queryset is modified with:: 192 | 193 | queryset = queryset.filter(account=Account.objects.get(pk=self.kwargs.get('account_pk'))) 194 | 195 | Also, helper methods are provided for each scope that doesn't disable it:: 196 | 197 | def get_account(self): 198 | return Account.objects.get(pk=self.kwargs.get('account_pk')) 199 | 200 | Technically, they are implemented with `__getattr__`, but each scope which doesn\'t have get_object_handle set to None 201 | will provide a get_X method (like `get_account` above) to obtain the object used for filtering. The object is kept cached 202 | on the view instance, so it can be reused during request handling without additional database queries. If the get_X method 203 | would be shadowed by something else, all scoped object are available via `view.get_scoped_object`:: 204 | 205 | def perform_create(self, serializer, **kwargs): 206 | kwargs['account'] = self.get_scoped_object('account') 207 | return super(UserViewSet, self).perform_create(serializer, **kwargs) 208 | 209 | This follows standard Django convention of naming foreign keys by `RelatedModel._meta.model_name` (same as scoped object access 210 | on view), using pk as primary key and modelname_pk as url kwarg. All of those parameters are configurable (see Scopes section below). 211 | 212 | For more complex cases, you can provide a list of scopes instead of a single scope. All of them will be applied to the queryset. 213 | 214 | Now let's say all your models need to remember who modified them recently. You don't really want to pass the logged in user to 215 | serializer in each view, and using threadlocals or globals isn't a good idea for this type of task. The solution to this problem 216 | would be a common view mixin. Let's say we place this in `myapp.mixins.py`:: 217 | 218 | class InjectUserMixin(object): 219 | def perform_update(self, serializer, **kwargs): 220 | kwargs['user'] = self.request.user 221 | return super(UserViewSet, self).perform_update(serializer, **kwargs) 222 | 223 | def perform_create(self, serializer, **kwargs): 224 | kwargs['user'] = self.request.user 225 | return super(UserViewSet, self).perform_create(serializer, **kwargs) 226 | 227 | And set `REST_EASY_GENERIC_VIEW_MIXINS` in your Django settings to:: 228 | 229 | REST_EASY_GENERIC_VIEW_MIXINS = ['myapp.mixins.InjectUserMixin'] 230 | 231 | Now all serializers will receive user as a parameter when calling `save()` from a update or create view. 232 | 233 | Scopes 234 | ====== 235 | 236 | Scopes are used to apply additional filters to views' querysets based on data obtainable form kwargs 237 | (:class:`rest_easy.scopes.UrlKwargScopeQuerySet`) and request (:class:`rest_easy.scopes.RequestAttrScopeQuerySet`). They should be used 238 | remove the boilerplate and bloat coming from filtering inside get_queryset or in dedicated mixins by providing a configurable wrapper 239 | for the filtering logic. 240 | 241 | There is also a base :class:`rest_easy.scopes.ScopeQuerySet` that you can inherit from to provide your own logic. When called, the 242 | ScopeQuerySet instance receives whole view object as a parameter, so it has access to everything that happens during the request as well 243 | as in application as a whole. 244 | 245 | Scopes can be chained (that is you can filter scope's queryset using another scope, just as it was a view; this supports lists of scopes 246 | as well). An example would be:: 247 | 248 | class MessageViewSet(ModelViewSet): 249 | model = Message 250 | schema = 'default' 251 | lookup_url_kwarg = 'pk' 252 | scope = UrlKwargScopeQuerySet(Thread, parent=UrlKwargScopeQuerySet(Account)) 253 | 254 | ScopeQuerySet 255 | ------------- 256 | 257 | When instantiating it, it accepts the following parameters (`{value}` is the filtering value obtained by concrete Scope implementation): 258 | 259 | * qs_or_obj: a queryset or model (in that case, the queryset would be `model.objects.all()`) that the scope works on. This can also 260 | be `None` in special cases (for example, when using :class:`rest_easy.scopes.RequestAttrScopeQuerySet` with `is_object=True`). 261 | For example, assuming you have a model Message that has foreign key to Thread, when scoping a `MessageViewSet` you would use 262 | `scope = ScopeQuerySet(Thread)`. 263 | * parent_field: the field qs_or_obj should be filtered by. By default it is pk. Following the example, the scope above would find the 264 | Thread object by `Thread.objects.all().filter(pk={value})`. 265 | * raise_404: If the instance we\'re scoping by isn\'t found (in the example, Thread with pk={value}), whether a 404 exception should be 266 | raised or should we continue as usual. By default False 267 | * allow_none: If the instance we\'re scoping by isn\'t found and 404 is not raised, whether to allow filtering child queryset with None 268 | (`allow_none=True`) or not - in this case we will filter with model.objects.none() and guarantee no results (`allow_none=False`). 269 | False by default. 270 | * get_object_handle: the name under which the object used for filtering (either None or result of applying {value} filter to queryset) 271 | will be available on the view. By default this is inferred to model_name. Can be set to None to disable access. It can be accessed 272 | from view as view.get_{get_object_handle}, so when using the above example, view.get_thread(). If the get_x method would be 273 | shadowed by something else, there is an option to call view.get_scoped_object(get_object_handle), so for example 274 | view.get_scoped_object(thread). 275 | * parent: parent scope. If present, qs_or_obj will be filtered by the scope or scopes passed as this parameter, just as if this was a 276 | view. 277 | 278 | UrlKwargScopeQuerySet 279 | --------------------- 280 | 281 | It obtains filtering value from `view.kwargs`. It takes one additional keyword argument: 282 | 283 | * url_kwarg: what is the name of kwarg (as given in url config) which has the value to filter by. By default it is configured to be 284 | model_name_pk (model name is obtained from qs_or_obj). 285 | 286 | Example:: 287 | 288 | scope = UrlKwargScopeQuerySet(Message.objects.active(), parent_field='uuid', url_kwarg='message_uuid', raise_404=True) 289 | queryset = scope.child_queryset(queryset, view) 290 | # is equal to roughly: 291 | queryset = queryset.filter(message=Message.objects.active().get(uuid=view.kwargs.get('message_uuid')) 292 | 293 | RequestAttrScopeQuerySet 294 | ------------------------ 295 | 296 | It obtains the filtering value from `view.request`. It takes two additional keyword arguments: 297 | 298 | * request_attr: the attribute in `view.request` that contains the filtering value or the object itself. 299 | * is_object: whether the request attribute contains object (True) or filtering value (False). By default True. 300 | 301 | Example with `is_object=True`:: 302 | 303 | scope = RequestAttrScopeQuerySet(User, request_attr='user') 304 | queryset = scope.child_queryset(queryset, view) 305 | # is roughly equal to: 306 | queryset = queryset.filter(user=view.request.user) 307 | 308 | Example with `is_object=False`:: 309 | 310 | scope = RequestAttrScopeQuerySet(User, request_attr='user', is_object=False) 311 | queryset = scope.child_queryset(queryset, view) 312 | # is roughly equal to: 313 | queryset = queryset.filter(user=User.objects.get(pk=view.request.user)) 314 | 315 | Helpers 316 | ======= 317 | 318 | There are following helpers available in :mod:`rest_easy.models`: 319 | 320 | * :class:`rest_easy.models.SerializableMixin` - it's supposed to be used on models. It provides 321 | :func:`rest_easy.models.SerializableMixin.get_serializer` method for obtaining model serializer given a schema and 322 | :func:`rest_easy.models.SerializableMixin.serialize` to serialize data (given schema or None, in which case the default schema is 323 | used. It can be set on a model, initially it's just `'default'`). 324 | * :func:`rest_easy.models.get_serializer` - looking at a blob of data, it obtains the serializer from register based on `data['model']` 325 | and `data['schema']`. 326 | * :func:`rest_easy.models.deserialize_data` - deserializes a blob of data if appropriate serializer is found. 327 | -------------------------------------------------------------------------------- /rest_easy/tests/test_all.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | # pylint: skip-file 3 | """ 4 | Tests for django-rest-easy. 5 | """ 6 | from __future__ import unicode_literals 7 | 8 | import six 9 | from django.conf import settings 10 | from django.http import Http404 11 | from django.test import TestCase 12 | 13 | from rest_easy.exceptions import RestEasyException 14 | from rest_easy.models import deserialize_data 15 | from rest_easy.patterns import SingletonCreator, Singleton, SingletonBase, RegisteredCreator, BaseRegister 16 | from rest_easy.registers import serializer_register 17 | from rest_easy.scopes import ScopeQuerySet, UrlKwargScopeQuerySet, RequestAttrScopeQuerySet 18 | from rest_easy.serializers import ModelSerializer, SerializerCreator 19 | from rest_easy.tests.models import * 20 | from rest_easy.views import ModelViewSet 21 | 22 | 23 | class Container(object): 24 | pass 25 | 26 | 27 | class BaseTestCase(TestCase): 28 | def setUp(self): 29 | pass 30 | 31 | def tearDown(self): 32 | serializer_register._entries = {} 33 | 34 | 35 | class SingletonDecoratorTest(BaseTestCase): 36 | """ 37 | This test case checks the init-regulating decorator. 38 | """ 39 | 40 | @classmethod 41 | def setUpClass(cls): 42 | """ 43 | This method sets up properties required to run the tests. 44 | :return: 45 | """ 46 | super(SingletonDecoratorTest, cls).setUpClass() 47 | 48 | class Test(object): 49 | """ 50 | This class enables properties, pure object doesn't. 51 | """ 52 | 53 | def __init__(self, sl_init=True): 54 | """ 55 | This method sets the parameter checked by singleton_decorator. 56 | """ 57 | self.sl_init = sl_init 58 | 59 | cls.Test = Test 60 | 61 | def func(param): 62 | """ 63 | This function is used to test the decorator. 64 | :param param: 65 | :return: 66 | """ 67 | if param: 68 | return 1 69 | 70 | cls.decorated = staticmethod(SingletonCreator.singleton_decorator(func)) 71 | cls.func = staticmethod(func) 72 | 73 | def test_properties(self): 74 | """ 75 | This test checks if the function gets decorated properly. 76 | :return: 77 | """ 78 | self.assertEqual(self.func.__name__, self.decorated.__name__) 79 | 80 | def test_init(self): 81 | """ 82 | This test checks whether initialization is done when it should be. 83 | :return: 84 | """ 85 | obj = self.Test(True) 86 | self.assertEqual(self.decorated(obj), 1) 87 | obj = self.Test(False) 88 | self.assertEqual(self.decorated(obj), None) 89 | 90 | def test_multiple_calls(self): 91 | """ 92 | This test checks a more life-like decorator usage with multiple calls. 93 | :return: 94 | """ 95 | obj = self.Test(True) 96 | self.assertEqual(self.decorated(obj), 1) 97 | obj.sl_init = False 98 | self.assertEqual(self.decorated(obj), None) 99 | self.assertEqual(self.decorated(obj), None) 100 | self.assertEqual(self.decorated(obj), None) 101 | obj.sl_init = True 102 | self.assertEqual(self.decorated(obj), 1) 103 | self.assertEqual(self.decorated(obj), 1) 104 | 105 | 106 | class SingletonTest(BaseTestCase): 107 | """ 108 | This test suite checks whether our extended singleton works as intended. 109 | """ 110 | 111 | @classmethod 112 | def setUpClass(cls): 113 | """ 114 | This method sets up a class required to proceed with the tests. 115 | :return: 116 | """ 117 | super(SingletonTest, cls).setUpClass() 118 | 119 | class Test(Singleton): 120 | """ 121 | This class is the bare requirement to test NamedSingleton. 122 | """ 123 | 124 | def __init__(self, param): 125 | """ 126 | This function allows us to check how many times it was called. 127 | :param param: 128 | :return: 129 | """ 130 | self.test = self.test + 1 if hasattr(self, 'test') else param 131 | self.var = None 132 | cls.Test = Test 133 | 134 | def test_same(self): 135 | object_a = self.Test(1) 136 | object_b = self.Test(5) 137 | self.assertTrue(object_a is object_b) 138 | object_a.var = 1 139 | self.assertEqual(object_b.var, 1) 140 | self.assertEqual(object_b.test, 1) 141 | self.assertEqual(object_a.test, 1) 142 | 143 | 144 | class TestCreator(BaseTestCase): 145 | def test_get_name(self): 146 | self.assertEqual('a', RegisteredCreator.get_name('a', None, None)) 147 | 148 | def test_get_fields_from_base(self): 149 | class A(object): 150 | a = 1 151 | b = 2 152 | 153 | def c(self): # pragma: no coverage 154 | pass 155 | 156 | fields = list(RegisteredCreator.get_fields_from_base(A)) 157 | self.assertEqual(len(fields), 2) 158 | self.assertIn(('a', 1), fields) 159 | self.assertIn(('b', 2), fields) 160 | 161 | def test_simple_required_fields(self): 162 | missing = RegisteredCreator.get_missing_fields({'a'}, {}) 163 | self.assertIn('a', missing) 164 | required_fields = { 165 | 'a': None, 166 | 'b': lambda x: x is not None 167 | } 168 | fields = {'b': None} 169 | missing = RegisteredCreator.get_missing_fields(required_fields, fields) 170 | self.assertIn('a', missing) 171 | self.assertIn('b', missing) 172 | 173 | def test_hooks(self): 174 | self.assertEqual((1, 2, 3), RegisteredCreator.pre_register(1, 2, 3)) 175 | self.assertEqual(None, RegisteredCreator.post_register(True, 1, 2, 3)) 176 | 177 | def test_field_inheritance(self): 178 | class Mock(object): 179 | a = {} 180 | 181 | RegisteredCreator.inherit_fields = True 182 | RegisteredCreator.register = BaseRegister() 183 | 184 | class Test(six.with_metaclass(RegisteredCreator, Mock)): 185 | pass 186 | 187 | self.assertEqual(Mock.a, Test.a) 188 | RegisteredCreator.inherit_fields = False 189 | 190 | def test_serializer_field_inheritance(self): 191 | class Mock(object): 192 | a = {} 193 | 194 | SerializerCreator.inherit_fields = True 195 | 196 | class Test(six.with_metaclass(SerializerCreator, Mock)): 197 | __abstract__ = True 198 | 199 | self.assertEqual(Mock.a, Test.a) 200 | SerializerCreator.inherit_fields = False 201 | 202 | 203 | class TestSerializers(BaseTestCase): 204 | def testModelSerializerMissingFields(self): 205 | def inner(): 206 | class MockSerializer(ModelSerializer): 207 | class Meta: 208 | fields = '__all__' 209 | model = MockModel 210 | 211 | def inner2(): 212 | class MockSerializer(ModelSerializer): 213 | class Meta: 214 | fields = '__all__' 215 | schema = 'default' 216 | 217 | def inner3(): 218 | class MockSerializer(ModelSerializer): 219 | class Meta: 220 | fields = '__all__' 221 | 222 | self.assertRaises(RestEasyException, inner) 223 | self.assertRaises(RestEasyException, inner2) 224 | self.assertRaises(RestEasyException, inner3) 225 | 226 | def testModelSerializerAutoFields(self): 227 | class MockSerializer(ModelSerializer): 228 | class Meta: 229 | fields = ('value', ) 230 | model = MockModel 231 | schema = 'default' 232 | 233 | self.assertTrue(MockSerializer._declared_fields['model'].value == 'rest_easy.MockModel') 234 | self.assertTrue(MockSerializer._declared_fields['schema'].value == 'default') 235 | self.assertIn('model', MockSerializer.Meta.fields) 236 | self.assertIn('schema', MockSerializer.Meta.fields) 237 | 238 | def testRegisterDuplication(self): 239 | def create(): 240 | class MockSerializer(ModelSerializer): 241 | class Meta: 242 | fields = '__all__' 243 | model = MockModel 244 | schema = 'default' 245 | return MockSerializer 246 | settings.REST_EASY_SERIALIZER_CONFLICT_POLICY = 'raise' 247 | create() 248 | self.assertRaises(RestEasyException, create) 249 | settings.REST_EASY_SERIALIZER_CONFLICT_POLICY = 'allow' 250 | ms = create() 251 | self.assertIn((serializer_register.get_name(MockModel, 'default'), ms), serializer_register.entries()) 252 | self.assertEqual(serializer_register.get(MockModel, 'default'), ms) 253 | 254 | def testRegisterAttributes(self): 255 | self.assertRaises(RestEasyException, lambda: serializer_register.get(object, 'schema')) 256 | 257 | def testModelSerializerAutoFieldsNoneModel(self): 258 | class MockSerializer(ModelSerializer): 259 | class Meta: 260 | fields = '__all__' 261 | model = None 262 | schema = 'default' 263 | 264 | self.assertTrue(MockSerializer._declared_fields['model'].value is None) 265 | self.assertTrue(MockSerializer._declared_fields['schema'].value == 'default') 266 | 267 | 268 | class TestModels(BaseTestCase): 269 | def setUp(self): 270 | super(TestModels, self).setUp() 271 | 272 | class MockSerializer(ModelSerializer): 273 | class Meta: 274 | fields = '__all__' 275 | model = MockModel 276 | schema = 'default' 277 | 278 | self.serializer = MockSerializer 279 | pass 280 | 281 | def test_get_serializer_success(self): 282 | taggable = MockModel(value='asd') 283 | self.assertEqual(taggable.get_serializer('default'), self.serializer) 284 | 285 | def test_get_serializer_failure(self): 286 | taggable = MockModel(value='asd') 287 | self.assertEqual(taggable.get_serializer('nope'), None) 288 | 289 | def test_serialize_success(self): 290 | taggable = MockModel(value='asd') 291 | serialized = taggable.serialize() 292 | self.assertEqual(serialized['model'], 'rest_easy.MockModel') 293 | self.assertEqual(serialized['schema'], 'default') 294 | self.assertEqual(serialized['value'], 'asd') 295 | 296 | def test_serialize_failure(self): 297 | taggable = MockModel(value='asd') 298 | self.assertRaises(RestEasyException, lambda: taggable.serialize('nope')) 299 | 300 | def test_deserialize_success(self): 301 | data = {'model': 'rest_easy.mockmodel', 'schema': 'default', 'value': 'zxc'} 302 | validated = deserialize_data(data) 303 | self.assertEqual(validated, {'value': data['value']}) 304 | 305 | def test_deserialize_failure(self): 306 | data = {'model': 'rest_easy.MockModel', 'value': 'zxc'} 307 | self.assertRaises(RestEasyException, lambda: deserialize_data(data)) 308 | data['schema'] = 'nonexistant' 309 | self.assertRaises(RestEasyException, lambda: deserialize_data(data)) 310 | 311 | 312 | class TestViews(BaseTestCase): 313 | def test_missing_fields(self): 314 | class FailingViewSet(ModelViewSet): 315 | pass 316 | 317 | self.assertIsNone(FailingViewSet.queryset, None) 318 | 319 | def test_queryset(self): 320 | class TaggableViewSet(ModelViewSet): 321 | queryset = MockModel.objects.all() 322 | model = MockModel2 323 | 324 | class AccountViewSet(ModelViewSet): 325 | model = MockModel2 326 | 327 | self.assertEqual(TaggableViewSet.queryset.model, MockModel) 328 | self.assertEqual(AccountViewSet.queryset.model, MockModel2) 329 | 330 | def test_scope_fails(self): 331 | class TaggableViewSet(ModelViewSet): 332 | model = MockModel 333 | scope = ScopeQuerySet(MockModel2) 334 | 335 | self.assertRaises(NotImplementedError, TaggableViewSet().get_queryset) 336 | 337 | def test_scope(self): 338 | class UserViewSet(ModelViewSet): 339 | model = User 340 | scope = UrlKwargScopeQuerySet(Account) 341 | vs = UserViewSet() 342 | vs.kwargs = {'account_pk': 1} 343 | self.assertEqual(0, vs.get_queryset().count()) 344 | 345 | def test_get_scope_object(self): 346 | mock = Container() 347 | 348 | class UserViewSet(ModelViewSet): 349 | model = User 350 | scope = RequestAttrScopeQuerySet(Account, request_attr='account') 351 | 352 | vs = UserViewSet() 353 | vs.request = Container() 354 | vs.request.account = mock 355 | self.assertEqual(mock, vs.get_account) 356 | self.assertRaises(AttributeError, lambda: vs.get_whatever()) 357 | 358 | def test_performs(self): 359 | class UserViewSet(ModelViewSet): 360 | model = User 361 | scope = UrlKwargScopeQuerySet(Account) 362 | 363 | class Serializer(object): 364 | def __init__(self, case, param): 365 | self.case = case 366 | self.param = param 367 | 368 | def save(self, param): 369 | self.case.assertEqual(param, self.param) 370 | 371 | s = Serializer(self, 'create') 372 | vs = UserViewSet() 373 | vs.perform_create(s, param='create') 374 | s.param = 'update' 375 | vs.perform_update(s, param='update') 376 | 377 | def test_serializer_class(self): 378 | class UserSerializer(ModelSerializer): 379 | class Meta: 380 | model = User 381 | schema = 'default' 382 | fields = '__all__' 383 | 384 | class UserRetrieveSerializer(ModelSerializer): 385 | class Meta: 386 | model = User 387 | schema = 'default-retrieve' 388 | fields = '__all__' 389 | 390 | class UserListSerializer(ModelSerializer): 391 | class Meta: 392 | model = User 393 | schema = 'default-list' 394 | fields = '__all__' 395 | 396 | class UserViewSet(ModelViewSet): 397 | model = User 398 | schema = 'default' 399 | serializer_schema_for_verb = {'retrieve': 'default-retrieve', 'list': 'default-list'} 400 | lookup_url_kwarg = 'pk' 401 | 402 | class UserViewSet2(ModelViewSet): 403 | serializer_class = UserSerializer 404 | 405 | vs = UserViewSet() 406 | vs.request = Container() 407 | vs.rest_easy_object_cache = {} 408 | 409 | # retrieve 410 | vs.kwargs = {'pk': 1} 411 | vs.request.method = 'get' 412 | self.assertEqual(vs.get_serializer_class(), UserRetrieveSerializer) 413 | # list 414 | vs.kwargs = {} 415 | self.assertEqual(vs.get_serializer_class(), UserListSerializer) 416 | #default 417 | vs.request.method = 'put' 418 | self.assertEqual(vs.get_serializer_class(), UserSerializer) 419 | #queryset 420 | vs = UserViewSet2() 421 | self.assertEqual(vs.get_serializer_class(), UserSerializer) 422 | 423 | def test_serializer_class_failing(self): 424 | class UserViewSet(ModelViewSet): 425 | model = User 426 | 427 | class UserViewSet2(ModelViewSet): 428 | pass 429 | 430 | vs = UserViewSet() 431 | vs.request = Container() 432 | vs.rest_easy_object_cache = {} 433 | vs.kwargs = {'pk': 1} 434 | vs.request.method = 'get' 435 | self.assertRaises(RestEasyException, vs.get_serializer_class) 436 | 437 | vs = UserViewSet2() 438 | vs.request = Container() 439 | vs.rest_easy_object_cache = {} 440 | vs.kwargs = {'pk': 1} 441 | vs.request.method = 'get' 442 | self.assertRaises(RestEasyException, vs.get_serializer_class) 443 | 444 | 445 | class TestScopeQuerySet(BaseTestCase): 446 | def setUp(self): 447 | self.account = Account.objects.create() 448 | self.other_account = Account.objects.create() 449 | self.user = User.objects.create(account=self.account) 450 | self.other_user = User.objects.create(account=self.other_account) 451 | 452 | def test_chaining(self): 453 | self.assertRaises(NotImplementedError, 454 | lambda: UrlKwargScopeQuerySet(Account, 455 | parent=ScopeQuerySet(Account.objects.all(), 456 | get_object_handle=None), 457 | get_object_handle=None 458 | ).child_queryset(None, None)) 459 | 460 | def test_creation_fails(self): 461 | self.assertRaises(RestEasyException, lambda: ScopeQuerySet(object)) 462 | self.assertRaises(RestEasyException, lambda: ScopeQuerySet(None)) 463 | self.assertRaises(RestEasyException, lambda: UrlKwargScopeQuerySet(None, related_field='a', 464 | get_object_handle=None)) 465 | self.assertRaises(RestEasyException, lambda: RequestAttrScopeQuerySet(None)) 466 | self.assertRaises(RestEasyException, lambda: RequestAttrScopeQuerySet(None, request_attr='a')) 467 | self.assertRaises(RestEasyException, lambda: RequestAttrScopeQuerySet(None, 468 | request_attr='a', 469 | related_field='a')) 470 | 471 | def test_contribute_to_class(self): 472 | view = Container() 473 | view.rest_easy_object_cache = {} 474 | view.rest_easy_available_object_handles = {} 475 | parent = UrlKwargScopeQuerySet(User, get_object_handle='test') 476 | scope = UrlKwargScopeQuerySet(Account, parent=parent) 477 | scope.contribute_to_class(view) 478 | self.assertEqual(view.rest_easy_available_object_handles['account'], scope) 479 | self.assertEqual(view.rest_easy_available_object_handles['test'], parent) 480 | self.assertRaises(RestEasyException, lambda: scope.contribute_to_class(view)) 481 | 482 | def test_url_kwarg(self): 483 | view = Container() 484 | view.rest_easy_object_cache = {} 485 | view.kwargs = {'account_pk': self.other_account.pk} 486 | 487 | qs = UrlKwargScopeQuerySet(Account).child_queryset(User.objects.all(), view) 488 | self.assertIn(self.other_user, list(qs)) 489 | self.assertEqual(1, len(list(qs))) 490 | 491 | def test_request_attrs(self): 492 | view = Container() 493 | view.rest_easy_object_cache = {} 494 | view.request = Container() 495 | view.request.account = self.other_account.pk 496 | 497 | qs = RequestAttrScopeQuerySet(Account, request_attr='account', 498 | is_object=False).child_queryset(User.objects.all(), view) 499 | self.assertIn(self.other_user, list(qs)) 500 | self.assertEqual(1, len(list(qs))) 501 | 502 | view.request.account = self.other_account 503 | qs = RequestAttrScopeQuerySet(Account, request_attr='account', 504 | is_object=True, get_object_handle=None).child_queryset(User.objects.all(), view) 505 | self.assertIn(self.other_user, list(qs)) 506 | self.assertEqual(1, len(list(qs))) 507 | 508 | def test_none(self): 509 | view = Container() 510 | view.rest_easy_object_cache = {} 511 | view.kwargs = {'account_pk': self.other_account.pk + 100} 512 | 513 | qs = UrlKwargScopeQuerySet(Account).child_queryset(User.objects.all(), view) 514 | self.assertEqual(0, len(list(qs))) 515 | 516 | def test_raises(self): 517 | view = Container() 518 | view.rest_easy_object_cache = {} 519 | view.kwargs = {'account_pk': self.other_account.pk + 100} 520 | 521 | self.assertRaises(Http404, 522 | lambda: UrlKwargScopeQuerySet(Account, 523 | raise_404=True).child_queryset(User.objects.all(), view)) 524 | --------------------------------------------------------------------------------