├── .python-version ├── setup.cfg ├── tests └── test_project │ ├── test_app │ ├── __init__.py │ ├── apps.py │ ├── models.py │ └── tests.py │ ├── pytest.ini │ ├── README.md │ ├── manage.py │ └── settings.py ├── MANIFEST.in ├── CHANGELOG.md ├── serializable.py ├── LICENSE ├── .gitignore ├── .travis.yml ├── maidfile.md ├── setup.py ├── pyproject.toml ├── django_serializable_model.py └── README.md /.python-version: -------------------------------------------------------------------------------- 1 | 3.7.4 2 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /tests/test_project/test_app/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include CHANGELOG.md 4 | -------------------------------------------------------------------------------- /tests/test_project/test_app/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TestAppConfig(AppConfig): 5 | name = 'test_app' 6 | -------------------------------------------------------------------------------- /tests/test_project/pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = settings 3 | python_files = tests.py 4 | addopts = --cov django_serializable_model 5 | --cov-branch 6 | --cov-report term-missing 7 | --cov-report html 8 | --cov-report xml 9 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | The changelog is currently hosted on [the GitHub Releases page](https://github.com/agilgur5/django-serializable-model/releases).
4 | It is currently mostly a summary and list of commits made before any tag. 5 | The commits in this library mostly follow a convention and tend to be quite detailed. 6 | 7 | This project adheres to [Semantic Versioning](http://semver.org/). 8 | -------------------------------------------------------------------------------- /tests/test_project/README.md: -------------------------------------------------------------------------------- 1 | # Django Test Project 2 | 3 | This is a barebones, as-minimal-as-possible Django project that is solely meant for testing. 4 | Outside of the actual test files, the other files are the minimum configuration necessary for getting this project to run with `pytest-django`. 5 | 6 | In order to run these tests, see [maidfile.md](../../maidfile.md) in the root directory of the repo. 7 | -------------------------------------------------------------------------------- /serializable.py: -------------------------------------------------------------------------------- 1 | """ 2 | This is just a pure wrapper / alias module around django_serializable_model to 3 | still be able to import it with the original, unintended name of serializable. 4 | See https://github.com/agilgur5/django-serializable-model/issues/2 5 | 6 | In the first major/breaking release, v1.0.0, this file should be deleted and 7 | the module removed from `setup.py`. 8 | """ 9 | 10 | from django_serializable_model import * # noqa F403, F401 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 Anton Gilgur 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /tests/test_project/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependency management 2 | # we use multiple versions of Python and deps, so lockfile doesn't quite fit 3 | # may add it back in later once Poetry usage stabilizes 4 | poetry.lock 5 | 6 | # testing and code coverage 7 | .pytest_cache/ 8 | .coverage 9 | htmlcov/ 10 | coverage.xml 11 | 12 | # Created by https://www.gitignore.io/api/python 13 | 14 | ### Python ### 15 | # Byte-compiled / optimized / DLL files 16 | __pycache__/ 17 | *.py[cod] 18 | *$py.class 19 | 20 | # C extensions 21 | *.so 22 | 23 | # Distribution / packaging 24 | .Python 25 | build/ 26 | develop-eggs/ 27 | dist/ 28 | downloads/ 29 | eggs/ 30 | .eggs/ 31 | lib/ 32 | lib64/ 33 | parts/ 34 | sdist/ 35 | var/ 36 | wheels/ 37 | *.egg-info/ 38 | .installed.cfg 39 | *.egg 40 | 41 | # Installer logs 42 | pip-log.txt 43 | pip-delete-this-directory.txt 44 | 45 | 46 | # End of https://www.gitignore.io/api/python 47 | -------------------------------------------------------------------------------- /tests/test_project/settings.py: -------------------------------------------------------------------------------- 1 | """ 2 | Django settings for test_project project. 3 | 4 | For more information on this file, see 5 | https://docs.djangoproject.com/en/2.2/topics/settings/ 6 | 7 | For the full list of settings and their values, see 8 | https://docs.djangoproject.com/en/2.2/ref/settings/ 9 | """ 10 | 11 | import os 12 | import django 13 | 14 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 15 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 16 | 17 | # required for running any Django tests 18 | SECRET_KEY = 'test_secret_key' 19 | 20 | # needed to discover models in Django tests 21 | INSTALLED_APPS = [ 22 | # AppConfig only exists in Django 1.9+ 23 | 'test_app.apps.TestAppConfig' if django.VERSION >= (1, 9) else 'test_app' 24 | ] 25 | 26 | # will use an in-memory database during testing when using sqlite3 engine 27 | DATABASES = { 28 | 'default': { 29 | 'ENGINE': 'django.db.backends.sqlite3', 30 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/test_project/test_app/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django_serializable_model import SerializableModel 3 | 4 | 5 | class User(SerializableModel): 6 | email = models.CharField(max_length=765, blank=True) 7 | name = models.CharField(max_length=100) 8 | # whitelisted fields that are allowed to be seen 9 | WHITELISTED_FIELDS = set([ 10 | 'name', 11 | ]) 12 | 13 | def serialize(self, *args, **kwargs): 14 | """Override serialize method to only serialize whitelisted fields""" 15 | fields = kwargs.pop('fields', self.WHITELISTED_FIELDS) 16 | return super(User, self).serialize(*args, fields=fields) 17 | 18 | 19 | class Settings(SerializableModel): 20 | user = models.OneToOneField(User, primary_key=True, 21 | on_delete=models.CASCADE) 22 | email_notifications = models.BooleanField(default=False) 23 | 24 | def serialize(self, *args): 25 | """Override serialize method to not serialize the user field""" 26 | return super(Settings, self).serialize(*args, exclude=['user']) 27 | 28 | 29 | class Post(SerializableModel): 30 | user = models.ForeignKey(User, on_delete=models.CASCADE) 31 | text = models.TextField() 32 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: '3.5' 3 | 4 | # test multiple Django versions 5 | matrix: 6 | include: 7 | # Python 3.x envs 8 | - env: DJANGO_VERSION=2.2 9 | install: poetry install # no need to override Django, this is default 10 | 11 | - env: DJANGO_VERSION=1.9 12 | 13 | - env: DJANGO_VERSION=1.5 14 | python: '3.4' # Python 3.5+ will error on Django < 1.8: https://stackoverflow.com/a/36000103/3431180 15 | # Poetry can't specify constraints that rely on other deps 16 | before_install: skip 17 | install: 18 | # django 1.5 needs pytest-django 2.9 needs pytest 3.5 needs pytest-cov 2.6 19 | - pip install django==1.5 pytest-django==2.9 pytest==3.5 pytest-cov==2.6 20 | - pip install -e . # won't be able to do this if setup.py is removed! 21 | 22 | # Python 2.7 envs 23 | - env: DJANGO_VERSION=1.11 # latest Django that supports 2.7 24 | python: '2.7' 25 | 26 | 27 | before_install: pip install "poetry>=1.0.0b3" 28 | install: 29 | - poetry install 30 | # override the version for each test 31 | - poetry add -D django="~$DJANGO_VERSION" 32 | 33 | script: pytest tests/test_project/ 34 | # upload coverage reports to CodeCov 35 | after_script: bash <(curl -s https://codecov.io/bash) 36 | -------------------------------------------------------------------------------- /tests/test_project/test_app/tests.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from .models import User, Settings, Post 3 | 4 | 5 | @pytest.mark.django_db 6 | def test_whitelist(): 7 | new_user = User.objects.create( 8 | name='John Doe', 9 | email='john@doe.com', 10 | ) 11 | 12 | assert new_user.serialize() == {'name': 'John Doe'} 13 | 14 | 15 | @pytest.mark.django_db 16 | def test_one_to_one(): 17 | new_user = User.objects.create( 18 | name='John Doe', 19 | email='john@doe.com', 20 | ) 21 | Settings.objects.create(user=new_user) 22 | 23 | new_user_refreshed = (User.objects.select_related('settings') 24 | .get(pk=new_user.pk)) 25 | 26 | assert new_user_refreshed.serialize() == {'name': 'John Doe'} 27 | # recursively serialize Settings object by passing the join in 28 | assert new_user_refreshed.serialize('settings') == \ 29 | {'name': 'John Doe', 'settings': {'email_notifications': False}} 30 | 31 | 32 | @pytest.mark.django_db 33 | def test_foreign_key(): 34 | new_user = User.objects.create( 35 | name='John Doe', 36 | email='john@doe.com', 37 | ) 38 | Post.objects.create(user=new_user, text='wat a nice post') 39 | Post.objects.create(user=new_user, text='another nice post') 40 | 41 | serialized_posts = [ 42 | {'id': 1, 'text': 'wat a nice post', 'user_id': 1}, 43 | {'id': 2, 'text': 'another nice post', 'user_id': 1} 44 | ] 45 | 46 | # called on QuerySet 47 | # adds an _id to the foreign key name, just like when using `.values()` 48 | assert Post.objects.all().serialize() == serialized_posts 49 | 50 | # called on Manager 51 | assert User.objects.get(pk=new_user.pk).post_set.serialize() == \ 52 | serialized_posts 53 | 54 | # recursively serialize Post objects by passing the join in 55 | assert (User.objects.prefetch_related('post_set').get(pk=new_user.pk) 56 | .serialize('post_set')) == \ 57 | {'name': 'John Doe', 'post_set': serialized_posts} 58 | -------------------------------------------------------------------------------- /maidfile.md: -------------------------------------------------------------------------------- 1 | # Tasks 2 | 3 | ## Prerequisites 4 | 5 | 1. `node.js` and `npm` (task runner and some tasks make use of JS ecosystem) 6 | 1. `npm i -g maid` or just run with `npx maid` 7 | 8 | ## Directions 9 | 10 | To run any of the tasks listed below (the headers), run `maid `. 11 | You can also see a list of the tasks and their descriptions with `maid help`. 12 | 13 | ## install 14 | 15 | Install dependencies with `poetry` 16 | 17 | ```bash 18 | poetry install; 19 | ``` 20 | 21 | ## test 22 | 23 | Runs tests with `pytest-django` and outputs coverage 24 | 25 | ```bash 26 | poetry run pytest tests/test_project/; 27 | ``` 28 | 29 | ## clean:dist 30 | 31 | Cleans distribution directories 32 | 33 | ```bash 34 | rm -rf build/ dist/ *.egg-info; 35 | ``` 36 | 37 | ## build:dist 38 | 39 | Builds a source distribution and binary wheel 40 | 41 | We use `setup.py` for releases as `poetry` does not yet fully support all the configuration we make use of. 42 | 43 | ```bash 44 | python setup.py sdist bdist_wheel; 45 | ``` 46 | 47 | ## __release:test 48 | 49 | Uploads a release to test PyPI. 50 | Internal use only (see `publish:test` for external usage). 51 | 52 | ```bash 53 | twine upload --repository-url https://test.pypi.org/legacy/ dist/*; 54 | ``` 55 | 56 | ## __release:prod 57 | 58 | Uploads a release to production PyPI. 59 | Internal use only (see `publish:prod` for external usage). 60 | 61 | ```bash 62 | twine upload dist/*; 63 | ``` 64 | 65 | ## publish:test 66 | 67 | `clean:dist` -> `build:dist` -> `__release:test` 68 | 69 | Run tasks `clean:dist`, `build:dist`, `__release:test` 70 | 71 | ## publish:prod 72 | 73 | `clean:dist` -> `build:dist` -> `__release:prod` 74 | 75 | Run tasks `clean:dist`, `build:dist`, `__release:prod` 76 | 77 | ## changelog 78 | 79 | Creates a changelog from the current tag to the previous tag 80 | 81 | ```bash 82 | # changelog-maker only gets name from package.json, so do a replace 83 | npx @agilgur5/changelog-maker | sed 's_nodejs/node_agilgur5/django-serializable-model_'; 84 | ``` 85 | 86 | ## Further Reading 87 | 88 | - [Maid Docs](https://github.com/egoist/maid) 89 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Always prefer setuptools over distutils 2 | from setuptools import setup 3 | # To use a consistent encoding 4 | from codecs import open 5 | from os import path 6 | 7 | 8 | here = path.abspath(path.dirname(__file__)) 9 | 10 | # Get the long description from the README file 11 | with open(path.join(here, 'README.md'), encoding='utf-8') as f: 12 | long_description = f.read() 13 | 14 | setup( 15 | name='django-serializable-model', 16 | version='0.0.6', 17 | description=('Django classes to make your models, managers, and ' + 18 | 'querysets serializable, with built-in support for related ' + 19 | 'objects in ~150 LoC'), 20 | long_description=long_description, 21 | long_description_content_type='text/markdown', 22 | url='https://github.com/agilgur5/django-serializable-model', 23 | author='Anton Gilgur', 24 | license='Apache-2.0', 25 | classifiers=[ 26 | 'Natural Language :: English', 27 | 'Intended Audience :: Developers', 28 | 'Topic :: Software Development :: Libraries', 29 | 'License :: OSI Approved :: Apache Software License', 30 | 31 | 'Development Status :: 3 - Alpha', 32 | 'Operating System :: OS Independent', 33 | 'Programming Language :: Python :: 2', 34 | 'Programming Language :: Python :: 2.7', 35 | 'Programming Language :: Python :: 3', 36 | 'Framework :: Django', 37 | 'Framework :: Django :: 1.4', 38 | 'Framework :: Django :: 1.5', 39 | 'Framework :: Django :: 1.6', 40 | 'Framework :: Django :: 1.7', 41 | 'Framework :: Django :: 1.8', 42 | 'Framework :: Django :: 1.9', 43 | 'Framework :: Django :: 1.10', 44 | 'Framework :: Django :: 1.11', 45 | 'Framework :: Django :: 2.0', 46 | 'Framework :: Django :: 2.1', 47 | 'Framework :: Django :: 2.2' 48 | ], 49 | keywords=('django serializer serializers serializer-django serialize ' + 50 | 'json dict queryset model modelmanager full wadofstuff'), 51 | py_modules=[ 52 | 'django_serializable_model', 53 | # this is the original, unintended name, and should be removed in the 54 | # first breaking/major release, v1.0.0. See `serializable.py` comment. 55 | 'serializable' 56 | ], 57 | python_requires='>=2.7, <4', 58 | project_urls={ # Optional 59 | 'Source': 'https://github.com/agilgur5/django-serializable-model/', 60 | 'Tracker': 'https://github.com/agilgur5/django-serializable-model/issues', # noqa 61 | }, 62 | ) 63 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "django-serializable-model" 3 | version = "0.0.6" 4 | description = "Django classes to make your models, managers, and querysets serializable, with built-in support for related objects in ~150 LoC" 5 | readme = "README.md" 6 | 7 | packages = [ 8 | { include = "django_serializable_model.py" }, 9 | # this is the original, unintended name, and should be removed in the 10 | # first breaking/major release, v1.0.0. See `serializable.py` comment. 11 | { include = "serializable.py" } 12 | ] 13 | include = [ 14 | "LICENSE", 15 | "CHANGELOG.md" 16 | ] 17 | 18 | authors = ["Anton Gilgur"] 19 | license = "Apache-2.0" 20 | homepage = "https://github.com/agilgur5/django-serializable-model" 21 | repository = "https://github.com/agilgur5/django-serializable-model" 22 | documentation = "https://github.com/agilgur5/django-serializable-model" 23 | 24 | classifiers=[ 25 | "Natural Language :: English", 26 | "Intended Audience :: Developers", 27 | "Topic :: Software Development :: Libraries", 28 | "License :: OSI Approved :: Apache Software License", 29 | 30 | "Development Status :: 3 - Alpha", 31 | "Operating System :: OS Independent", 32 | "Programming Language :: Python :: 2", 33 | "Programming Language :: Python :: 2.7", 34 | "Programming Language :: Python :: 3", 35 | "Framework :: Django", 36 | "Framework :: Django :: 1.4", 37 | "Framework :: Django :: 1.5", 38 | "Framework :: Django :: 1.6", 39 | "Framework :: Django :: 1.7", 40 | "Framework :: Django :: 1.8", 41 | "Framework :: Django :: 1.9", 42 | "Framework :: Django :: 1.10", 43 | "Framework :: Django :: 1.11", 44 | "Framework :: Django :: 2.0", 45 | "Framework :: Django :: 2.1", 46 | "Framework :: Django :: 2.2" 47 | ] 48 | keywords=[ 49 | "django", 50 | "serializer", 51 | "serializers", 52 | "serializer-django", 53 | "serialize", 54 | "json", 55 | "dict", 56 | "queryset", 57 | "model", 58 | "modelmanager", 59 | "full", 60 | "wadofstuff" 61 | ] 62 | 63 | [tool.poetry.urls] 64 | "Tracker" = "https://github.com/agilgur5/django-serializable-model/issues" 65 | 66 | [tool.poetry.dependencies] 67 | python = "^2.7 || ^3.5" 68 | 69 | [tool.poetry.dev-dependencies] 70 | django = [ 71 | {version = "^2.2", python = "^3.5"}, 72 | {version = "^1.11", python = "^2.7"} 73 | ] 74 | pytest = [ 75 | {version = "^5.1", python = "^3.5"}, 76 | {version = "<5", python = "^2.7"} 77 | ] 78 | pytest-django = "^3.5" 79 | pytest-cov = "^2.7" 80 | 81 | [build-system] 82 | requires = ["poetry>=1.0.0b3"] 83 | build-backend = "poetry.masonry.api" 84 | -------------------------------------------------------------------------------- /django_serializable_model.py: -------------------------------------------------------------------------------- 1 | import django 2 | from django.db import models 3 | from django.core.exceptions import ObjectDoesNotExist 4 | 5 | 6 | # noqa | all models need an explicit or inferred app_label (https://stackoverflow.com/q/4382032/3431180) 7 | # noqa | abstract models shouldn't though -- this is fixed in 1.8+ (https://code.djangoproject.com/ticket/24981) 8 | # change __name__ instead of explicitly setting app_label as that would then 9 | # need to be overridden by Models that extend this 10 | if django.VERSION < (1, 8): 11 | __name__ = 'django_serializable_model.django_serializable_model' 12 | 13 | 14 | class _SerializableQuerySet(models.query.QuerySet): 15 | """Implements the serialize method on a QuerySet""" 16 | def serialize(self, *args): 17 | serialized = [] 18 | for elem in self: 19 | serialized.append(elem.serialize(*args)) 20 | return serialized 21 | 22 | 23 | class SerializableManager(models.Manager): 24 | """Implements table-level serialization via SerializableQuerySet""" 25 | # replaced by base_manager_name in Model.Meta in Django 1.10+ 26 | if django.VERSION < (1, 10): 27 | # when queried from a related Model, use this Manager 28 | use_for_related_fields = True 29 | 30 | def get_queryset(self): 31 | return _SerializableQuerySet(self.model) 32 | 33 | # renamed to get_queryset in Django 1.6+ 34 | if django.VERSION < (1, 6): 35 | get_query_set = get_queryset 36 | 37 | def get_queryset_compat(self): 38 | get_queryset = (self.get_query_set 39 | if hasattr(self, 'get_query_set') 40 | else self.get_queryset) 41 | return get_queryset() 42 | 43 | # implement serialize on the Manager itself (on .objects, before .all()) 44 | def serialize(self, *args): 45 | return self.get_queryset_compat().serialize(*args) 46 | 47 | 48 | class SerializableModel(models.Model): 49 | """ 50 | Abstract Model that implements recursive serializability of models to 51 | dictionaries, both at the row and table level, with some overriding allowed 52 | """ 53 | objects = SerializableManager() 54 | 55 | # this is needed due to the __name__ hackiness; will be incorrect and cause 56 | # Django to error when loading this model otherwise 57 | if django.VERSION < (1, 8): 58 | __module__ = 'django_serializable_model' 59 | 60 | class Meta: 61 | abstract = True 62 | 63 | # doesn't exist in <1.10, so Meta's typecheck will throw without guard 64 | if django.VERSION >= (1, 10): 65 | # when queried from a related Model, use this Manager 66 | base_manager_name = 'objects' 67 | 68 | def serialize(self, *args, **kwargs): 69 | """ 70 | Serializes the Model object with model_to_dict_custom and kwargs, and 71 | proceeds to recursively serialize related objects as requested in args 72 | """ 73 | serialized = model_to_dict_custom(self, **kwargs) 74 | args = list(args) # convert tuple to list 75 | 76 | # iterate and recurse through all arguments 77 | index = 0 78 | length = len(args) 79 | while index < length: 80 | # split the current element 81 | field_with_joins = args[index] 82 | field, join = _split_joins(field_with_joins) 83 | all_joins = [join] if join else [] # empty string to empty array 84 | 85 | # delete it from the list 86 | del args[index] 87 | length -= 1 88 | 89 | # get all joins for this field from the arguments 90 | arg_joins = [_split_joins(arg, only_join=True) 91 | for arg in args if arg.startswith(field)] 92 | all_joins += arg_joins # combine all joins on this field 93 | 94 | # recurse if related object actually exists 95 | try: 96 | serialized[field] = getattr(self, field).serialize(*all_joins) 97 | except (AttributeError, ObjectDoesNotExist): 98 | pass 99 | 100 | # shrink length and remove all args that were recursed over 101 | length -= len(arg_joins) 102 | args = [arg for arg in args if not arg.startswith(field)] 103 | 104 | return serialized 105 | 106 | 107 | def model_to_dict_custom(instance, fields=None, exclude=None, editable=True): 108 | """ 109 | Custom model_to_dict function that differs by including all uneditable 110 | fields and excluding all M2M fields by default 111 | Also sets all ForeignKey fields to name + _id, similar to .values() 112 | """ 113 | # avoid circular import 114 | from django.db.models.fields.related import ForeignKey 115 | opts = instance._meta 116 | data = {} 117 | for f in opts.fields: 118 | # skip uneditable fields if editable kwarg is False 119 | if not editable and not f.editable: 120 | continue 121 | # whitelisted fields only if fields kwarg is passed 122 | if fields and f.name not in fields: 123 | continue 124 | # blacklist fields from exclude kwarg 125 | if exclude and f.name in exclude: 126 | continue 127 | else: 128 | if isinstance(f, ForeignKey): 129 | data[f.name + '_id'] = f.value_from_object(instance) 130 | else: 131 | data[f.name] = f.value_from_object(instance) 132 | return data 133 | 134 | 135 | def _split_joins(join_string, only_join=False): 136 | """ 137 | Split a string into the field and it's joins, separated by __ as per 138 | Django convention 139 | """ 140 | split = join_string.split('__') 141 | field = split.pop(0) # the first field 142 | join = '__'.join(split) # the rest of the fields 143 | 144 | # return single join or tuple based on kwarg 145 | if only_join: 146 | return join 147 | return field, join 148 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # django-serializable-model 2 | 3 | 4 | [![PyPI version](https://img.shields.io/pypi/v/django-serializable-model.svg)](https://pypi.org/project/django-serializable-model/) 5 | [![releases](https://img.shields.io/github/tag-pre/agilgur5/django-serializable-model.svg)](https://github.com/agilgur5/django-serializable-model/releases) 6 | [![commits](https://img.shields.io/github/commits-since/agilgur5/django-serializable-model/latest.svg)](https://github.com/agilgur5/django-serializable-model/commits/master) 7 |
8 | [![dm](https://img.shields.io/pypi/dm/django-serializable-model.svg)](https://pypi.org/project/django-serializable-model/) 9 | [![dw](https://img.shields.io/pypi/dw/django-serializable-model.svg)](https://pypi.org/project/django-serializable-model/) 10 |
11 | [![build status](https://img.shields.io/travis/agilgur5/django-serializable-model/master.svg)](https://travis-ci.org/agilgur5/django-serializable-model) 12 | [![code coverage](https://img.shields.io/codecov/c/gh/agilgur5/django-serializable-model/master.svg)](https://codecov.io/gh/agilgur5/django-serializable-model) 13 |
14 | Django classes to make your models, managers, and querysets serializable, with built-in support for related objects in ~100 LoC (shorter than this README!) 15 | 16 | ## Table of Contents 17 | 18 | I. [Installation](#installation)
19 | II. [Usage](#usage)
20 | III. [How it Works](#how-it-works)
21 | IV. [Related Libraries](#related-libraries)
22 | V. [Backstory](#backstory) 23 | 24 | ## Installation 25 | 26 | ```shell 27 | pip install django-serializable-model 28 | ``` 29 | 30 | It is expected that you already have Django installed 31 | 32 | ### Compatibility 33 | 34 | [![Python versions](https://img.shields.io/pypi/pyversions/django-serializable-model.svg)](https://pypi.org/project/django-serializable-model/) 35 | [![Django versions](https://img.shields.io/pypi/djversions/django-serializable-model.svg)](https://pypi.org/project/django-serializable-model/) 36 | 37 | [Tested](https://travis-ci.org/agilgur5/django-serializable-model) on Django 2.2, 1.11, 1.9, and 1.5 as well as Python 3.5, 3.4, and 2.7 38 | 39 | - Should work with Django 1.4-2.x and Python 2.7-3.x 40 | - Has several Django backward & forward compatibility fixes built-in 41 | - Likely works with Django 0.95-1.3 as well 42 | - Pre 1.3 does not support the [`on_delete` argument](https://django.readthedocs.io/en/1.3.X/releases/1.3.html#configurable-delete-cascade) on relations. 43 | This only affects the usage and examples below; the internals are unaffected. 44 | - Pre 0.95, the Manager API didn't exist, so some functionality may be limited in those versions, or it may just error on import 45 | - Have not confirmed if this works with earlier versions of Python. 46 | 47 | Please submit a PR or file an issue if you have a compatibility problem or have confirmed compatibility on versions. 48 | 49 |
50 | 51 | ## Usage 52 | 53 | Simplest use case, just implements the `.serialize()` function on a model: 54 | 55 | ```python 56 | from django.db import models 57 | from django_serializable_model import SerializableModel 58 | 59 | 60 | class User(SerializableModel): 61 | email = models.CharField(max_length=765, blank=True) 62 | name = models.CharField(max_length=100) 63 | 64 | 65 | new_user = User.objects.create( 66 | name='John Doe', 67 | email='john@doe.com', 68 | ) 69 | 70 | print new_user.serialize() 71 | # {'id': 1, 'email': 'john@doe.com', 'name': 'John Doe'} 72 | ``` 73 | 74 |
75 | 76 | With an override of the default `.serialize()` function to only include whitelisted fields in the serialized dictionary: 77 | 78 | ```python 79 | from django.db import models 80 | from django_serializable_model import SerializableModel 81 | 82 | 83 | class User(SerializableModel): 84 | email = models.CharField(max_length=765, blank=True) 85 | name = models.CharField(max_length=100) 86 | # whitelisted fields that are allowed to be seen 87 | WHITELISTED_FIELDS = set([ 88 | 'name', 89 | ]) 90 | 91 | 92 | def serialize(self, *args, **kwargs): 93 | """Override serialize method to only serialize whitelisted fields""" 94 | fields = kwargs.pop('fields', self.WHITELISTED_FIELDS) 95 | return super(User, self).serialize(*args, fields=fields) 96 | 97 | 98 | new_user = User.objects.create( 99 | name='John Doe', 100 | email='john@doe.com', 101 | ) 102 | 103 | print new_user.serialize() 104 | # {'name': 'John Doe'} 105 | ``` 106 | 107 |
108 | 109 | With a simple, one-to-one relation: 110 | 111 | ```python 112 | from django.db import models 113 | from django_serializable_model import SerializableModel 114 | 115 | 116 | class User(SerializableModel): 117 | email = models.CharField(max_length=765, blank=True) 118 | name = models.CharField(max_length=100) 119 | 120 | 121 | class Settings(SerializableModel): 122 | user = models.OneToOneField(User, primary_key=True, 123 | on_delete=models.CASCADE) 124 | email_notifications = models.BooleanField(default=False) 125 | 126 | def serialize(self, *args): 127 | """Override serialize method to not serialize the user field""" 128 | return super(Settings, self).serialize(*args, exclude=['user']) 129 | 130 | 131 | new_user = User.objects.create( 132 | name='John Doe', 133 | email='john@doe.com', 134 | ) 135 | Settings.objects.create(user=new_user) 136 | 137 | new_user_refreshed = User.objects.select_related('settings').get(pk=new_user.pk) 138 | 139 | print new_user_refreshed.serialize() 140 | # {'id': 1, 'email': 'john@doe.com', 'name': 'John Doe'} 141 | 142 | # recursively serialize Settings object by passing the join in 143 | print new_user_refreshed.serialize('settings') 144 | # {'id': 1, 'email': 'john@doe.com', 'settings': {'email_notifications': False}, 'name': 'John Doe'} 145 | ``` 146 | 147 |
148 | 149 | With a foreign key relation: 150 | 151 | ```python 152 | from django.db import models 153 | from django_serializable_model import SerializableModel 154 | 155 | 156 | class User(SerializableModel): 157 | email = models.CharField(max_length=765, blank=True) 158 | name = models.CharField(max_length=100) 159 | 160 | 161 | class Post(SerializableModel): 162 | user = models.ForeignKey(User, on_delete=models.CASCADE) 163 | text = models.TextField() 164 | 165 | 166 | new_user = User.objects.create( 167 | name='John Doe', 168 | email='john@doe.com', 169 | ) 170 | Post.objects.create(user=new_user, text='wat a nice post') 171 | Post.objects.create(user=new_user, text='another nice post') 172 | 173 | # called on QuerySet 174 | print Post.objects.all().serialize() 175 | # [{'id': 1, 'text': 'wat a nice post', 'user_id': 1}, {'id': 2, 'text': 'another nice post', 'user_id': 1}] 176 | # adds an _id to the foreign key name, just like when using `.values()` 177 | 178 | # called on Manager 179 | user1 = User.objects.get(pk=new_user.pk) 180 | print user1.post_set.serialize() 181 | # [{'id': 1, 'text': 'wat a nice post', 'user_id': 1}, {'id': 2, 'text': 'another nice post', 'user_id': 1}] 182 | 183 | # recursively serialize Post objects by passing the join in 184 | print User.objects.prefetch_related('post_set').get(pk=new_user.pk).serialize('post_set') 185 | """ 186 | { 187 | 'id': 1, 188 | 'email': 'john@doe.com', 189 | 'name': 'John Doe', 190 | 'post_set': [{'id': 1, 'text': 'wat a nice post', 'user_id': 1}, {'id': 2, 'text': 'another nice post', 'user_id': 1}] 191 | } 192 | """ 193 | ``` 194 | 195 |
196 | 197 | `.serialize` takes in any number of joins as its `*args` and they can be of any depth, using the same `__` syntax as [`prefetch_related`](https://docs.djangoproject.com/en/2.0/ref/models/querysets/#prefetch-related). This means if your `Post` object also had `Comment` objects, you could write: 198 | 199 | ```python 200 | User.objects.prefetch_related('post_set__comment_set').serialize('post_set__comment_set') 201 | ``` 202 | 203 | and get an array of `Comment` dictionaries within each `Post` dictionary. If your `Post` object also had `Like` objects: 204 | 205 | ```python 206 | joins = ['post_set__comment_set', 'post_set__like_set'] 207 | User.objects.prefetch_related(*joins).serialize(*joins) 208 | ``` 209 | 210 |
211 | 212 | ### JSON and APIs 213 | 214 | Since `.serialize` outputs a dictionary, one can turn it into JSON simply by using `json.dumps` on the dictionary. 215 | 216 | If you're building an API, you can use `JSONResponse` on the dictionary as well. 217 | 218 |
219 | 220 | ## How it works 221 | 222 | Implementing a `.serialize` method on Models, Managers, and QuerySets allows for easily customizable whitelists and blacklists (among other things) on a per Model basis. 223 | This type of behavior was not possible a simple recursive version of `model_to_dict`, but is often necessary for various security measures and overrides. 224 | In order to recurse over relations / joins, it accepts the same arguments as the familiar `prefetch_related`, which, in my use cases, often immediately precedes the `.serialize` calls. 225 | `.serialize` also uses a custom `model_to_dict` function that behaves a bit differently than the built-in one in a variety of ways that are more expected when building an API (see the docstring). 226 | 227 | I'd encourage you to read the source code, since it's shorter than this README :) 228 | 229 | ## Related Libraries 230 | 231 | - [django-api-decorators](https://github.com/agilgur5/django-api-decorators) 232 | - `Tiny decorator functions to make it easier to build an API using Django in ~100 LoC` 233 | 234 |
235 | 236 | ## Backstory 237 | 238 | This library was built while I was working on [Yorango](https://github.com/Yorango)'s ad-hoc API. Writing code to serialize various models was complex and quite tedious, resulting in messy spaghetti code for many of our API methods. The only solutions I could find online were the [Django Full Serializers](http://code.google.com/p/wadofstuff/wiki/DjangoFullSerializers) from [wadofstuff](https://github.com/mattimustang/wadofstuff) as well as some recursive `model_to_dict` snippets online -- none of which gave the option for customizable whitelists and blacklists on a per Model basis. 239 | Later on, I found that [Django REST Framework's ModelSerializers](http://www.django-rest-framework.org/api-guide/serializers#modelserializer) do offer similar functionality to what I was looking for (and _without_ requiring buy-in to the rest of the framework), albeit with some added complexity and robustness. 240 | 241 | I ended up writing my own solution in ~100 LoC that handled basically all of my needs and replaced a ton of messy serialiazation code from all around the codebase. It was used in production with fantastic results, including on queries with quite the complexity and depth, such as: 242 | 243 | ```python 244 | 245 | joins = ['unit_set', 'unit_set__listing_set', 246 | 'unit_set__listing_set__tenants', 'unit_set__listing_set__bill_set', 247 | 'unit_set__listing_set__payment_set__payer', 248 | 'unit_set__listing_set__contract'] 249 | s_props = (user.property_set.all().prefetch_related(*joins) 250 | .serialize(*joins)) 251 | 252 | ``` 253 | 254 | Had been meaning to extract and open source this as well as other various useful utility libraries I had made at Yorango and finally got the chance! 255 | --------------------------------------------------------------------------------