├── .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 | [](https://pypi.org/project/django-serializable-model/)
5 | [](https://github.com/agilgur5/django-serializable-model/releases)
6 | [](https://github.com/agilgur5/django-serializable-model/commits/master)
7 |
8 | [](https://pypi.org/project/django-serializable-model/)
9 | [](https://pypi.org/project/django-serializable-model/)
10 |
11 | [](https://travis-ci.org/agilgur5/django-serializable-model)
12 | [](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 | [](https://pypi.org/project/django-serializable-model/)
35 | [](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 |
--------------------------------------------------------------------------------