├── .flake8 ├── .gitignore ├── .travis.yml ├── LICENSE ├── PYPI_README.rst ├── README.md ├── contributors.rst ├── docs ├── Makefile ├── acknowledgments.rst ├── basic-usage.rst ├── conf.py ├── filtering.rst ├── flat-options.rst ├── index.rst ├── installation.rst ├── make.bat ├── object-options.rst ├── one-to-two.rst ├── pagination.rst ├── release-notes.rst └── viewsets.rst ├── drf_multiple_model ├── __init__.py ├── mixins.py ├── models.py ├── pagination.py ├── views.py └── viewsets.py ├── manage.py ├── requirements.txt ├── setup.py ├── tests ├── __init__.py ├── models.py ├── serializers.py ├── settings.py ├── test_flat_view.py ├── test_html_renderer.py ├── test_object_view.py ├── test_pagination.py ├── test_viewsets.py └── utils.py └── tox.ini /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 119 3 | exclude = */migrations/*,.tox/* 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # OS generated files # 2 | ###################### 3 | .DS_Store 4 | .DS_Store? 5 | ._* 6 | .Spotlight-V100 7 | .Trashes 8 | ehthumbs.db 9 | Thumbs.db 10 | 11 | # Compiled Code # 12 | ################# 13 | *.pyc 14 | 15 | # testing tools # 16 | ################# 17 | htmlcov/ 18 | ghostdriver.log 19 | .coverage 20 | .tox/ 21 | build/ 22 | 23 | # Packaging info # 24 | ################## 25 | MANIFEST.in 26 | dist/ 27 | django_rest_multiple_models.egg-info/ 28 | 29 | # Environment # 30 | ############### 31 | setup.sh 32 | Vagrantfile 33 | .vagrant/ 34 | testproj/ 35 | *swp 36 | db.sqlite3 37 | env/ 38 | 39 | # Docs # 40 | ######## 41 | docs/_build/ 42 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | sudo: required 3 | dist: xenial 4 | python: 5 | - "2.7" 6 | - "3.4" 7 | - "3.5" 8 | - "3.6" 9 | - "3.7" 10 | env: 11 | matrix: 12 | - DJANGO="Django<2" 13 | - DJANGO="Django<2.1" 14 | - DJANGO="Django<2.2" 15 | install: 16 | - pip install "$DJANGO" coverage coveralls "djangorestframework>=3.9" flake8 17 | - pip install . 18 | matrix: 19 | exclude: 20 | - python: "3.7" 21 | env: DJANGO="Django<2" 22 | - python: "2.7" 23 | env: DJANGO="Django<2.1" 24 | - python: "2.7" 25 | env: DJANGO="Django<2.2" 26 | - python: "3.4" 27 | env: DJANGO="Django<2.2" 28 | branches: 29 | only: 30 | - master 31 | before_script: 32 | - "flake8 ." 33 | script: 34 | - coverage run --source=drf_multiple_model manage.py test 35 | after_success: 36 | - coverage report 37 | - coveralls 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2018 Matt Broach 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /PYPI_README.rst: -------------------------------------------------------------------------------- 1 | ================================ 2 | Django Rest Multiple Model View 3 | ================================ 4 | 5 | drf-multiple-models provides an easy view (and mixin) for serializing multiple models in a single view. It is built on top of (and meant to be an extension for) Django Rest Framework. 6 | 7 | Installation 8 | ------------ 9 | 10 | 11 | 1. Install the package from pip: 12 | 13 | .. code-block:: python 14 | 15 | pip install django-rest-multiple-models 16 | 17 | 2. Make sure to add 'drf_multiple_model' to your INSTALLED_APPS: 18 | 19 | .. code-block:: python 20 | 21 | INSTALLED_APPS = ( 22 | ... 23 | 'drf_multiple_model', 24 | ) 25 | 26 | 3. Then simply import the view into any views.py in which you'd want to use it: 27 | 28 | .. code-block:: python 29 | 30 | from drf_multiple_model.views import MultipleModelAPIView 31 | 32 | 33 | Usage 34 | ----- 35 | 36 | Documentation is on ReadTheDocs: 37 | https://django-rest-multiple-models.readthedocs.io/en/latest/ 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/MattBroach/DjangoRestMultipleModels.svg?branch=master)](https://travis-ci.org/MattBroach/DjangoRestMultipleModels) 2 | [![Coverage Status](https://coveralls.io/repos/github/Axiologue/DjangoRestMultipleModels/badge.svg?branch=master)](https://coveralls.io/github/Axiologue/DjangoRestMultipleModels?branch=master) 3 | [![PyPI version](https://badge.fury.io/py/django-rest-multiple-models.svg)](https://badge.fury.io/py/django-rest-multiple-models) 4 | 5 | # Multiple Model View 6 | 7 | [Django Rest Framework](https://github.com/tomchristie/django-rest-framework) provides some incredible tools for serializing data, but sometimes you need to combine many serializers and/or models into a single API call. **drf-multiple-model** is an app designed to do just that. 8 | 9 | * Full Documentation: [https://django-rest-multiple-models.readthedocs.org/en/latest/](https://django-rest-multiple-models.readthedocs.io/en/latest/) 10 | * Source Code: [https://github.com/Axiologue/DjangoRestMultipleModels](https://github.com/Axiologue/DjangoRestMultipleModels) 11 | * PyPI: [https://pypi.python.org/pypi/django-rest-multiple-models](https://pypi.python.org/pypi/django-rest-multiple-models) 12 | * License: MIT 13 | 14 | # Installation 15 | 16 | Install the package from pip: 17 | 18 | ``` 19 | pip install django-rest-multiple-models 20 | ``` 21 | 22 | Make sure to add 'drf_multiple_model' to your INSTALLED_APPS: 23 | 24 | ```python 25 | INSTALLED_APPS = ( 26 | .... 27 | 'drf_multiple_model', 28 | ) 29 | ``` 30 | 31 | Then simply import the view into any views.py in which you'd want to use it: 32 | 33 | ```python 34 | from drf_multiple_model.views import ObjectMultipleModelAPIView 35 | ``` 36 | 37 | **Note:** This package is built on top of Django Rest Framework's generic views and serializers, so it presupposes that Django Rest Framework is installed and added to your project as well. 38 | 39 | # Features 40 | 41 | * Send multiple serialized models as separate arrays, one merged list, or a single JSON object 42 | * Sort different serialized models using shared fields 43 | * pagination 44 | * Filtering -- either per queryset or on all querysets 45 | * custom model labeling 46 | 47 | For full configuration options, filtering tools, and more, see [the documentation](https://django-rest-multiple-models.readthedocs.org/en/latest/). 48 | 49 | # Basic Usage 50 | 51 | **drf-multiple-model** comes with two generic class-based-view for serializing multiple models: the `ObjectMultipleModelAPIView` and the `FlatMultipleModelAPIView`. Both views require a `querylist` attribute, which is a list or tuple of dicts containing (at minimum) a `queryset` key and a `serializer_class` key; the main difference between the views is the format of the response data. For example, let's say you have the following models and serializers: 52 | 53 | ```python 54 | # Models 55 | class Play(models.Model): 56 | genre = models.CharField(max_length=100) 57 | title = models.CharField(max_length=200) 58 | pages = models.IntegerField() 59 | 60 | class Poem(models.Model): 61 | title = models.CharField(max_length=200) 62 | style = models.CharField(max_length=100) 63 | lines = models.IntegerField() 64 | stanzas = models.IntegerField() 65 | 66 | # Serializers 67 | class PlaySerializer(serializers.ModelSerializer): 68 | class Meta: 69 | model = Play 70 | fields = ('genre','title','pages') 71 | 72 | class PoemSerializer(serializers.ModelSerializer): 73 | class Meta: 74 | model = Poem 75 | fields = ('title','stanzas') 76 | ``` 77 | 78 | Then you might use the `ObjectMultipleModelAPIView` as follows: 79 | 80 | 81 | ```python 82 | from drf_multiple_model.views import ObjectMultipleModelAPIView 83 | 84 | class TextAPIView(ObjectMultipleModelAPIView): 85 | querylist = [ 86 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 87 | {'queryset': Poem.objects.filter(style='Sonnet'), 'serializer_class': PoemSerializer}, 88 | .... 89 | ] 90 | ``` 91 | 92 | which would return: 93 | 94 | ```python 95 | { 96 | "Play" : [ 97 | {"genre": "Comedy", "title": "A Midsummer Night"s Dream", "pages": 350}, 98 | {"genre": "Tragedy", "title": "Romeo and Juliet", "pages": 300}, 99 | .... 100 | ], 101 | "Poem" : [ 102 | {"title": "Shall I compare thee to a summer"s day", "stanzas": 1}, 103 | {"title": "As a decrepit father takes delight", "stanzas": 1}, 104 | .... 105 | ], 106 | } 107 | ``` 108 | 109 | Or you could use the `FlatMultipleModelAPIView` as follows: 110 | 111 | ```python 112 | from drf_multiple_model.views import FlatMultipleModelAPIView 113 | 114 | class TextAPIView(FlatMultipleModelAPIView): 115 | querylist = [ 116 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 117 | {'queryset': Poem.objects.filter(style='Sonnet'), 'serializer_class': PoemSerializer}, 118 | .... 119 | ] 120 | ``` 121 | 122 | which would return:: 123 | 124 | ```python 125 | [ 126 | {"genre": "Comedy", "title": "A Midsummer Night's Dream", "pages": 350, "type": "Play"}, 127 | {"genre": "Tragedy", "title": "Romeo and Juliet", "pages": 300, "type": "Play"}, 128 | .... 129 | {"title": "Shall I compare thee to a summer's day", "stanzas": 1, "type": "Poem"}, 130 | {"title": "As a decrepit father takes delight", "stanzas": 1, "type": "Poem"}, 131 | .... 132 | ] 133 | ``` 134 | -------------------------------------------------------------------------------- /contributors.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Contributors 3 | ============ 4 | 5 | Project Maintainer and Founder 6 | ============================== 7 | 8 | * Matt Nishi-Broach 9 | 10 | Contributors 11 | ============ 12 | 13 | * rbreu 14 | * Paul Tiplady 15 | * schweickism 16 | * ELIYAHUT123 17 | * Malcolm Box 18 | * Evgen Osiptsov 19 | * Alexander Anikeev 20 | * lerela 21 | -------------------------------------------------------------------------------- /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 | # Internal variables. 11 | PAPEROPT_a4 = -D latex_paper_size=a4 12 | PAPEROPT_letter = -D latex_paper_size=letter 13 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 14 | # the i18n builder cannot share the environment and doctrees with the others 15 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 16 | 17 | .PHONY: help 18 | help: 19 | @echo "Please use \`make ' where is one of" 20 | @echo " html to make standalone HTML files" 21 | @echo " dirhtml to make HTML files named index.html in directories" 22 | @echo " singlehtml to make a single large HTML file" 23 | @echo " pickle to make pickle files" 24 | @echo " json to make JSON files" 25 | @echo " htmlhelp to make HTML files and a HTML help project" 26 | @echo " qthelp to make HTML files and a qthelp project" 27 | @echo " applehelp to make an Apple Help Book" 28 | @echo " devhelp to make HTML files and a Devhelp project" 29 | @echo " epub to make an epub" 30 | @echo " epub3 to make an epub3" 31 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 32 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 33 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 34 | @echo " text to make text files" 35 | @echo " man to make manual pages" 36 | @echo " texinfo to make Texinfo files" 37 | @echo " info to make Texinfo files and run them through makeinfo" 38 | @echo " gettext to make PO message catalogs" 39 | @echo " changes to make an overview of all changed/added/deprecated items" 40 | @echo " xml to make Docutils-native XML files" 41 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 42 | @echo " linkcheck to check all external links for integrity" 43 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 44 | @echo " coverage to run coverage check of the documentation (if enabled)" 45 | @echo " dummy to check syntax errors of document sources" 46 | 47 | .PHONY: clean 48 | clean: 49 | rm -rf $(BUILDDIR)/* 50 | 51 | .PHONY: html 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | .PHONY: dirhtml 58 | dirhtml: 59 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 60 | @echo 61 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 62 | 63 | .PHONY: singlehtml 64 | singlehtml: 65 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 66 | @echo 67 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 68 | 69 | .PHONY: pickle 70 | pickle: 71 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 72 | @echo 73 | @echo "Build finished; now you can process the pickle files." 74 | 75 | .PHONY: json 76 | json: 77 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 78 | @echo 79 | @echo "Build finished; now you can process the JSON files." 80 | 81 | .PHONY: htmlhelp 82 | htmlhelp: 83 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 84 | @echo 85 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 86 | ".hhp project file in $(BUILDDIR)/htmlhelp." 87 | 88 | .PHONY: qthelp 89 | qthelp: 90 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 91 | @echo 92 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 93 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 94 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/DjangoRestMultipleModels.qhcp" 95 | @echo "To view the help file:" 96 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/DjangoRestMultipleModels.qhc" 97 | 98 | .PHONY: applehelp 99 | applehelp: 100 | $(SPHINXBUILD) -b applehelp $(ALLSPHINXOPTS) $(BUILDDIR)/applehelp 101 | @echo 102 | @echo "Build finished. The help book is in $(BUILDDIR)/applehelp." 103 | @echo "N.B. You won't be able to view it unless you put it in" \ 104 | "~/Library/Documentation/Help or install it in your application" \ 105 | "bundle." 106 | 107 | .PHONY: devhelp 108 | devhelp: 109 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 110 | @echo 111 | @echo "Build finished." 112 | @echo "To view the help file:" 113 | @echo "# mkdir -p $$HOME/.local/share/devhelp/DjangoRestMultipleModels" 114 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/DjangoRestMultipleModels" 115 | @echo "# devhelp" 116 | 117 | .PHONY: epub 118 | epub: 119 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 120 | @echo 121 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 122 | 123 | .PHONY: epub3 124 | epub3: 125 | $(SPHINXBUILD) -b epub3 $(ALLSPHINXOPTS) $(BUILDDIR)/epub3 126 | @echo 127 | @echo "Build finished. The epub3 file is in $(BUILDDIR)/epub3." 128 | 129 | .PHONY: latex 130 | latex: 131 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 132 | @echo 133 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 134 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 135 | "(use \`make latexpdf' here to do that automatically)." 136 | 137 | .PHONY: latexpdf 138 | latexpdf: 139 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 140 | @echo "Running LaTeX files through pdflatex..." 141 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 142 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 143 | 144 | .PHONY: latexpdfja 145 | latexpdfja: 146 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 147 | @echo "Running LaTeX files through platex and dvipdfmx..." 148 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 149 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 150 | 151 | .PHONY: text 152 | text: 153 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 154 | @echo 155 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 156 | 157 | .PHONY: man 158 | man: 159 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 160 | @echo 161 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 162 | 163 | .PHONY: texinfo 164 | texinfo: 165 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 166 | @echo 167 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 168 | @echo "Run \`make' in that directory to run these through makeinfo" \ 169 | "(use \`make info' here to do that automatically)." 170 | 171 | .PHONY: info 172 | info: 173 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 174 | @echo "Running Texinfo files through makeinfo..." 175 | make -C $(BUILDDIR)/texinfo info 176 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 177 | 178 | .PHONY: gettext 179 | gettext: 180 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 181 | @echo 182 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 183 | 184 | .PHONY: changes 185 | changes: 186 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 187 | @echo 188 | @echo "The overview file is in $(BUILDDIR)/changes." 189 | 190 | .PHONY: linkcheck 191 | linkcheck: 192 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 193 | @echo 194 | @echo "Link check complete; look for any errors in the above output " \ 195 | "or in $(BUILDDIR)/linkcheck/output.txt." 196 | 197 | .PHONY: doctest 198 | doctest: 199 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 200 | @echo "Testing of doctests in the sources finished, look at the " \ 201 | "results in $(BUILDDIR)/doctest/output.txt." 202 | 203 | .PHONY: coverage 204 | coverage: 205 | $(SPHINXBUILD) -b coverage $(ALLSPHINXOPTS) $(BUILDDIR)/coverage 206 | @echo "Testing of coverage in the sources finished, look at the " \ 207 | "results in $(BUILDDIR)/coverage/python.txt." 208 | 209 | .PHONY: xml 210 | xml: 211 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 212 | @echo 213 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 214 | 215 | .PHONY: pseudoxml 216 | pseudoxml: 217 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 218 | @echo 219 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 220 | 221 | .PHONY: dummy 222 | dummy: 223 | $(SPHINXBUILD) -b dummy $(ALLSPHINXOPTS) $(BUILDDIR)/dummy 224 | @echo 225 | @echo "Build finished. Dummy builder generates no files." 226 | -------------------------------------------------------------------------------- /docs/acknowledgments.rst: -------------------------------------------------------------------------------- 1 | .. include:: ../contributors.rst 2 | -------------------------------------------------------------------------------- /docs/basic-usage.rst: -------------------------------------------------------------------------------- 1 | ===== 2 | Usage 3 | ===== 4 | 5 | Basic Usage 6 | ============ 7 | 8 | **drf-multiple-model** comes with two generic class-based-view for serializing multiple models: the ``ObjectMultipleModelAPIView`` and the ``FlatMultipleModelAPIView``. Both views require a ``querylist`` attribute, which is a list or tuple of dicts containing (at minimum) a ``queryset`` key and a ``serializer_class`` key; the main difference between the views is the format of the response data. For example, let's say you have the following models and serializers:: 9 | 10 | # Models 11 | class Play(models.Model): 12 | genre = models.CharField(max_length=100) 13 | title = models.CharField(max_length=200) 14 | pages = models.IntegerField() 15 | 16 | class Poem(models.Model): 17 | title = models.CharField(max_length=200) 18 | style = models.CharField(max_length=100) 19 | lines = models.IntegerField() 20 | stanzas = models.IntegerField() 21 | 22 | # Serializers 23 | class PlaySerializer(serializers.ModelSerializer): 24 | class Meta: 25 | model = Play 26 | fields = ('genre','title','pages') 27 | 28 | class PoemSerializer(serializers.ModelSerializer): 29 | class Meta: 30 | model = Poem 31 | fields = ('title','stanzas') 32 | 33 | Then you might use the ``ObjectMultipleModelAPIView`` as follows:: 34 | 35 | from drf_multiple_model.views import ObjectMultipleModelAPIView 36 | 37 | class TextAPIView(ObjectMultipleModelAPIView): 38 | querylist = [ 39 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 40 | {'queryset': Poem.objects.filter(style='Sonnet'), 'serializer_class': PoemSerializer}, 41 | .... 42 | ] 43 | 44 | which would return:: 45 | 46 | { 47 | 'Play' : [ 48 | {'genre': 'Comedy', 'title': "A Midsummer Night's Dream", 'pages': 350}, 49 | {'genre': 'Tragedy', 'title': "Romeo and Juliet", 'pages': 300}, 50 | .... 51 | ], 52 | 'Poem' : [ 53 | {'title': 'Shall I compare thee to a summer's day?', 'stanzas': 1}, 54 | {'title': 'As a decrepit father takes delight', 'stanzas': 1}, 55 | .... 56 | ], 57 | } 58 | 59 | Or you coulde use the ``FlatMultipleModelAPIView`` as follows:: 60 | 61 | from drf_multiple_model.views import FlatMultipleModelAPIView 62 | 63 | class TextAPIView(FlatMultipleModelAPIView): 64 | querylist = [ 65 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 66 | {'queryset': Poem.objects.filter(style='Sonnet'), 'serializer_class': PoemSerializer}, 67 | .... 68 | ] 69 | 70 | which would return:: 71 | 72 | [ 73 | {'genre': 'Comedy', 'title': "A Midsummer Night's Dream", 'pages': 350, 'type': 'Play'}, 74 | {'genre': 'Tragedy', 'title': "Romeo and Juliet", 'pages': 300, 'type': 'Play'}, 75 | .... 76 | {'title': 'Shall I compare thee to a summer's day?', 'stanzas': 1, 'type': 'Poem'}, 77 | {'title': 'As a decrepit father takes delight', 'stanzas': 1, 'type': 'Poem'}, 78 | .... 79 | ] 80 | 81 | 82 | Mixins 83 | ====== 84 | 85 | If you want to combine ``ObjectMultipleModelAPIView`` or ``FlatMultipleModelAPIViews``'s ``list()`` function with other views, you can use their base mixins from ``mixins.py`` instead. 86 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # -*- coding: utf-8 -*- 3 | # 4 | # DjangoRestMultipleModels documentation build configuration file, created by 5 | # sphinx-quickstart on Thu Jun 9 15:00:02 2016. 6 | # 7 | # This file is execfile()d with the current directory set to its 8 | # containing dir. 9 | # 10 | # Note that not all possible configuration values are present in this 11 | # autogenerated file. 12 | # 13 | # All configuration values have a default; values that are commented out 14 | # serve to show the default. 15 | 16 | # If extensions (or modules to document with autodoc) are in another directory, 17 | # add these directories to sys.path here. If the directory is relative to the 18 | # documentation root, use os.path.abspath to make it absolute, like shown here. 19 | # 20 | # import os 21 | # import sys 22 | # sys.path.insert(0, os.path.abspath('.')) 23 | 24 | # -- General configuration ------------------------------------------------ 25 | 26 | # If your documentation needs a minimal Sphinx version, state it here. 27 | # 28 | # needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | import os 34 | import re 35 | 36 | 37 | def get_init_prop(prop, f): 38 | return re.search("__{}__ = ['\"]([^'\"]+)['\"]".format(prop), f).group(1) 39 | 40 | 41 | base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 42 | 43 | with open(os.path.join(base_dir, 'drf_multiple_model', '__init__.py')) as f: 44 | init_py = f.read() 45 | 46 | extensions = [] 47 | 48 | # Add any paths that contain templates here, relative to this directory. 49 | templates_path = ['_templates'] 50 | 51 | 52 | # The suffix(es) of source filenames. 53 | # You can specify multiple suffix as a list of string: 54 | # 55 | # source_suffix = ['.rst', '.md'] 56 | source_suffix = '.rst' 57 | 58 | # The encoding of source files. 59 | # 60 | # source_encoding = 'utf-8-sig' 61 | 62 | # The master toctree document. 63 | master_doc = 'index' 64 | 65 | # General information about the project. 66 | project = 'DjangoRestMultipleModels' 67 | copyright = get_init_prop('copyright', init_py) 68 | author = get_init_prop('author', init_py) 69 | 70 | # The version info for the project you're documenting, acts as replacement for 71 | # |version| and |release|, also used in various other places throughout the 72 | # built documents. 73 | # 74 | # The short X.Y version. 75 | version = get_init_prop('version', init_py) 76 | # The full version, including alpha/beta/rc tags. 77 | release = get_init_prop('version', init_py) 78 | 79 | # The language for content autogenerated by Sphinx. Refer to documentation 80 | # for a list of supported languages. 81 | # 82 | # This is also used if you do content translation via gettext catalogs. 83 | # Usually you set "language" from the command line for these cases. 84 | language = None 85 | 86 | # There are two options for replacing |today|: either, you set today to some 87 | # non-false value, then it is used: 88 | # 89 | # today = '' 90 | # 91 | # Else, today_fmt is used as the format for a strftime call. 92 | # 93 | # today_fmt = '%B %d, %Y' 94 | 95 | # List of patterns, relative to source directory, that match files and 96 | # directories to ignore when looking for source files. 97 | # This patterns also effect to html_static_path and html_extra_path 98 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 99 | 100 | # The reST default role (used for this markup: `text`) to use for all 101 | # documents. 102 | # 103 | # default_role = None 104 | 105 | # If true, '()' will be appended to :func: etc. cross-reference text. 106 | # 107 | # add_function_parentheses = True 108 | 109 | # If true, the current module name will be prepended to all description 110 | # unit titles (such as .. function::). 111 | # 112 | # add_module_names = True 113 | 114 | # If true, sectionauthor and moduleauthor directives will be shown in the 115 | # output. They are ignored by default. 116 | # 117 | # show_authors = False 118 | 119 | # The name of the Pygments (syntax highlighting) style to use. 120 | pygments_style = 'sphinx' 121 | 122 | # A list of ignored prefixes for module index sorting. 123 | # modindex_common_prefix = [] 124 | 125 | # If true, keep warnings as "system message" paragraphs in the built documents. 126 | # keep_warnings = False 127 | 128 | # If true, `todo` and `todoList` produce output, else they produce nothing. 129 | todo_include_todos = False 130 | 131 | 132 | # -- Options for HTML output ---------------------------------------------- 133 | 134 | # The theme to use for HTML and HTML Help pages. See the documentation for 135 | # a list of builtin themes. 136 | # 137 | html_theme = 'sphinx_rtd_theme' 138 | 139 | # Theme options are theme-specific and customize the look and feel of a theme 140 | # further. For a list of options available for each theme, see the 141 | # documentation. 142 | # 143 | # html_theme_options = {} 144 | 145 | # Add any paths that contain custom themes here, relative to this directory. 146 | # html_theme_path = [] 147 | 148 | # The name for this set of Sphinx documents. 149 | # " v documentation" by default. 150 | # 151 | # html_title = 'DjangoRestMultipleModels v1.7' 152 | 153 | # A shorter title for the navigation bar. Default is the same as html_title. 154 | # 155 | # html_short_title = None 156 | 157 | # The name of an image file (relative to this directory) to place at the top 158 | # of the sidebar. 159 | # 160 | # html_logo = None 161 | 162 | # The name of an image file (relative to this directory) to use as a favicon of 163 | # the docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 164 | # pixels large. 165 | # 166 | # html_favicon = None 167 | 168 | # Add any paths that contain custom static files (such as style sheets) here, 169 | # relative to this directory. They are copied after the builtin static files, 170 | # so a file named "default.css" will overwrite the builtin "default.css". 171 | html_static_path = ['_static'] 172 | 173 | # Add any extra paths that contain custom files (such as robots.txt or 174 | # .htaccess) here, relative to this directory. These files are copied 175 | # directly to the root of the documentation. 176 | # 177 | # html_extra_path = [] 178 | 179 | # If not None, a 'Last updated on:' timestamp is inserted at every page 180 | # bottom, using the given strftime format. 181 | # The empty string is equivalent to '%b %d, %Y'. 182 | # 183 | # html_last_updated_fmt = None 184 | 185 | # If true, SmartyPants will be used to convert quotes and dashes to 186 | # typographically correct entities. 187 | # 188 | # html_use_smartypants = True 189 | 190 | # Custom sidebar templates, maps document names to template names. 191 | # 192 | # html_sidebars = {} 193 | 194 | # Additional templates that should be rendered to pages, maps page names to 195 | # template names. 196 | # 197 | # html_additional_pages = {} 198 | 199 | # If false, no module index is generated. 200 | # 201 | # html_domain_indices = True 202 | 203 | # If false, no index is generated. 204 | # 205 | # html_use_index = True 206 | 207 | # If true, the index is split into individual pages for each letter. 208 | # 209 | # html_split_index = False 210 | 211 | # If true, links to the reST sources are added to the pages. 212 | # 213 | # html_show_sourcelink = True 214 | 215 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 216 | # 217 | # html_show_sphinx = True 218 | 219 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 220 | # 221 | # html_show_copyright = True 222 | 223 | # If true, an OpenSearch description file will be output, and all pages will 224 | # contain a tag referring to it. The value of this option must be the 225 | # base URL from which the finished HTML is served. 226 | # 227 | # html_use_opensearch = '' 228 | 229 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 230 | # html_file_suffix = None 231 | 232 | # Language to be used for generating the HTML full-text search index. 233 | # Sphinx supports the following languages: 234 | # 'da', 'de', 'en', 'es', 'fi', 'fr', 'h', 'it', 'ja' 235 | # 'nl', 'no', 'pt', 'ro', 'r', 'sv', 'tr', 'zh' 236 | # 237 | # html_search_language = 'en' 238 | 239 | # A dictionary with options for the search language support, empty by default. 240 | # 'ja' uses this config value. 241 | # 'zh' user can custom change `jieba` dictionary path. 242 | # 243 | # html_search_options = {'type': 'default'} 244 | 245 | # The name of a javascript file (relative to the configuration directory) that 246 | # implements a search results scorer. If empty, the default will be used. 247 | # 248 | # html_search_scorer = 'scorer.js' 249 | 250 | # Output file base name for HTML help builder. 251 | htmlhelp_basename = 'DjangoRestMultipleModelsdoc' 252 | 253 | # -- Options for LaTeX output --------------------------------------------- 254 | 255 | latex_elements = { 256 | # The paper size ('letterpaper' or 'a4paper'). 257 | # 258 | # 'papersize': 'letterpaper', 259 | 260 | # The font size ('10pt', '11pt' or '12pt'). 261 | # 262 | # 'pointsize': '10pt', 263 | 264 | # Additional stuff for the LaTeX preamble. 265 | # 266 | # 'preamble': '', 267 | 268 | # Latex figure (float) alignment 269 | # 270 | # 'figure_align': 'htbp', 271 | } 272 | 273 | # Grouping the document tree into LaTeX files. List of tuples 274 | # (source start file, target name, title, 275 | # author, documentclass [howto, manual, or own class]). 276 | latex_documents = [ 277 | (master_doc, 'DjangoRestMultipleModels.tex', 'DjangoRestMultipleModels Documentation', 278 | 'Matt Nishi-Broach', 'manual'), 279 | ] 280 | 281 | # The name of an image file (relative to this directory) to place at the top of 282 | # the title page. 283 | # 284 | # latex_logo = None 285 | 286 | # For "manual" documents, if this is true, then toplevel headings are parts, 287 | # not chapters. 288 | # 289 | # latex_use_parts = False 290 | 291 | # If true, show page references after internal links. 292 | # 293 | # latex_show_pagerefs = False 294 | 295 | # If true, show URL addresses after external links. 296 | # 297 | # latex_show_urls = False 298 | 299 | # Documents to append as an appendix to all manuals. 300 | # 301 | # latex_appendices = [] 302 | 303 | # If false, no module index is generated. 304 | # 305 | # latex_domain_indices = True 306 | 307 | 308 | # -- Options for manual page output --------------------------------------- 309 | 310 | # One entry per manual page. List of tuples 311 | # (source start file, name, description, authors, manual section). 312 | man_pages = [ 313 | (master_doc, 'djangorestmultiplemodels', 'DjangoRestMultipleModels Documentation', 314 | [author], 1) 315 | ] 316 | 317 | # If true, show URL addresses after external links. 318 | # 319 | # man_show_urls = False 320 | 321 | 322 | # -- Options for Texinfo output ------------------------------------------- 323 | 324 | # Grouping the document tree into Texinfo files. List of tuples 325 | # (source start file, target name, title, author, 326 | # dir menu entry, description, category) 327 | texinfo_documents = [ 328 | (master_doc, 'DjangoRestMultipleModels', 'DjangoRestMultipleModels Documentation', 329 | author, 'DjangoRestMultipleModels', 'One line description of project.', 330 | 'Miscellaneous'), 331 | ] 332 | 333 | # Documents to append as an appendix to all manuals. 334 | # 335 | # texinfo_appendices = [] 336 | 337 | # If false, no module index is generated. 338 | # 339 | # texinfo_domain_indices = True 340 | 341 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 342 | # 343 | # texinfo_show_urls = 'footnote' 344 | 345 | # If true, do not generate a @detailmenu in the "Top" node's menu. 346 | # 347 | # texinfo_no_detailmenu = False 348 | -------------------------------------------------------------------------------- /docs/filtering.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Filtering 3 | ========= 4 | 5 | Django Rest Framework Filters 6 | ============================= 7 | 8 | Django Rest Frameworks default Filter Backends work out of the box. These filters will be applied to **every** queryset in your queryList. For example, using the `SearchFilter` Backend in a view:: 9 | 10 | class SearchFilterView(ObjectMultipleModelAPIView): 11 | querylist = ( 12 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 13 | {'queryset': Poem.objects.filter(style="Sonnet"), 'serializer_class': PoemSerializer}, 14 | ) 15 | filter_backends = (filters.SearchFilter,) 16 | search_fields = ('title',) 17 | 18 | accessed with a url like ``http://www.example.com/texts?search=as`` would return only the ``Plays`` and ``Poems`` with "as" in the title:: 19 | 20 | { 21 | 'Play': [ 22 | {'title':'As You Like It','genre':'Comedy','year':1623}, 23 | ], 24 | 'Poem': [ 25 | {'title':"As a decrepit father takes delight",'style':'Sonnet'}, 26 | ] 27 | } 28 | 29 | Per Queryset Filtering 30 | ====================== 31 | 32 | Using the built in Filter Backends is a nice DRY solution, but it doesn't work well if you want to apply the filter to some items in your queryList, but not others. In order to apply more targeted queryset filtering, DRF Multiple Models provides two technique: 33 | 34 | Override get_querylist() 35 | ------------------------ 36 | 37 | **drf-multiple-model** now supports the creation of dynamic queryLists, by overwriting the get_querylist() function rather than simply specifying the queryList variable. This allows you to do things like construct queries using url kwargs, etc:: 38 | 39 | class DynamicQueryView(ObjectMultipleModelAPIView): 40 | def get_querylist(self): 41 | title = self.request.query_params['play'].replace('-',' ') 42 | 43 | querylist = ( 44 | {'queryset': Play.objects.filter(title=title), 'serializer_class': PlaySerializer}, 45 | {'queryset': Poem.objects.filter(style="Sonnet"), 'serializer_class': PoemSerializer}, 46 | ) 47 | 48 | return querylist 49 | 50 | That view, if accessed via a url like ``http://www.example.com/texts?play=Julius-Caesar`` would return only plays that match the provided title, but the poems would be untouched:: 51 | 52 | { 53 | 'play': [ 54 | {'title':'Julius Caesar','genre':'Tragedy','year':1623}, 55 | ], 56 | 'poem': [ 57 | {'title':"Shall I compare thee to a summer's day?",'style':'Sonnet'}, 58 | {'title':"As a decrepit father takes delight",'style':'Sonnet'} 59 | ], 60 | } 61 | 62 | Custom Filter Functions 63 | ----------------------- 64 | 65 | If you want to create a more complicated filter or use a custom filtering function, you can pass a custom filter function as an element in your querylist using the ``filter_fn`` key:: 66 | 67 | from drf_multiple_model.views import MultipleModelAPIView 68 | 69 | def title_without_letter(queryset, request, *args, **kwargs): 70 | letter_to_exclude = request.query_params['letter'] 71 | return queryset.exclude(title__icontains=letter_to_exclude) 72 | 73 | class FilterFnView(MultipleModelAPIView): 74 | querylist = ( 75 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer, 'filter_fn': title_without_letter}, 76 | {'queryset': Poem.objects.all(), 'serializer_class':PoemSerializer}, 77 | ) 78 | 79 | The above view will use the ``title_without_letter()`` function to filter the queryset and remove and title that contains the provided letter. Accessed from the url ``http://www.example.com/texts?letter=o`` would return all plays without the letter 'o', but the poems would be untouched:: 80 | 81 | { 82 | 'play': [ 83 | {'title':"A Midsummer Night's Dream",'genre':'Comedy','year':1600}, 84 | {'title':'Julius Caesar','genre':'Tragedy','year':1623}, 85 | ], 86 | 'poem': [ 87 | {'title':"Shall I compare thee to a summer's day?",'style':'Sonnet'}, 88 | {'title':"As a decrepit father takes delight",'style':'Sonnet'}, 89 | {'title':"A Lover's Complaint",'style':'Narrative'} 90 | ], 91 | } 92 | -------------------------------------------------------------------------------- /docs/flat-options.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | FlatMultipleModelAPIView Options 3 | ================================== 4 | 5 | Labels 6 | ====== 7 | 8 | By default, ``FlatMultipleModelAPIView`` adds a ``type`` property to returned items with the model name. If you want to use a custom value for the ``type`` property other than the model name, you can add a ``label`` key to your ``querylist`` dicts, like so:: 9 | 10 | from drf_multiple_model.views import FlatMultipleModelAPIView 11 | 12 | class TextAPIView(FlatMultipleModelAPIView): 13 | querylist = [ 14 | { 15 | 'queryset': Play.objects.all(), 16 | 'serializer_class': PlaySerializer, 17 | 'label': 'drama', 18 | }, 19 | { 20 | 'queryset': Poem.objects.filter(style='Sonnet'), 21 | 'serializer_class': PoemSerializer, 22 | 'label': 'sonnet' 23 | }, 24 | .... 25 | ] 26 | 27 | which would return:: 28 | 29 | [ 30 | {'genre': 'Comedy', 'title': "A Midsummer Night's Dream", 'pages': 350, 'type': 'drama'}, 31 | {'genre': 'Tragedy', 'title': "Romeo and Juliet", 'pages': 300, 'type': 'drama'}, 32 | .... 33 | {'title': 'Shall I compare thee to a summer's day?', 'stanzas': 1, 'type': 'sonnet'}, 34 | {'title': 'As a decrepit father takes delight', 'stanzas': 1, 'type': 'sonnet'}, 35 | .... 36 | ] 37 | 38 | If you'd prefer not to add the ``type`` property to returned items, you can set the class-level field of ``add_model_type`` to ``False``:: 39 | 40 | class TextAPIView(FlatMultipleModelAPIView): 41 | add_model_type = False 42 | 43 | querylist = [ 44 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 45 | {'queryset': Poem.objects.filter(style='Sonnet'), 'serializer_class': PoemSerializer}, 46 | .... 47 | ] 48 | 49 | which would return:: 50 | 51 | [ 52 | {'genre': 'Comedy', 'title': "A Midsummer Night's Dream", 'pages': 350}, 53 | {'genre': 'Tragedy', 'title': "Romeo and Juliet", 'pages': 300}, 54 | .... 55 | {'title': 'Shall I compare thee to a summer's day?', 'stanzas': 1}, 56 | {'title': 'As a decrepit father takes delight', 'stanzas': 1}, 57 | .... 58 | ] 59 | 60 | **Note:** adding a custom label to your querylist elements will **always** override ``add_model_type``. However, labels are taken on an element-by-element basis, so you can add labels for some of your models/querysets, but not others. 61 | 62 | sorting_field 63 | ============= 64 | 65 | By default the objects will be arranged by the order in which the querysets were listed in your ``querylist`` attribute. However, you can specify a different ordering by adding the ``sorting_fields`` to your view, which works similar to Django's ``ordering``:: 66 | 67 | class TextAPIView(FlatMultipleModelAPIView): 68 | sorting_fields = ['title'] 69 | 70 | querylist = [ 71 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 72 | {'queryset': Poem.objects.filter(style='Sonnet'), 'serializer_class': PoemSerializer}, 73 | .... 74 | ] 75 | 76 | would return:: 77 | 78 | [ 79 | {'genre': 'Comedy', 'title': "A Midsummer Night's Dream", 'pages': 350, 'type': 'Play'}, 80 | {'title': 'As a decrepit father takes delight', 'stanzas': 1, 'type': 'Poem'}, 81 | {'genre': 'Tragedy', 'title': "Romeo and Juliet", 'pages': 300, 'type': 'Play'}, 82 | {'title': 'Shall I compare thee to a summer's day?', 'stanzas': 1, 'type': 'Poem'}, 83 | .... 84 | ] 85 | 86 | As with django field ordering, add '-' to the beginning of the field to enable reverse sorting. Setting ``sorting_fields=['-title', 'name']`` would sort the title fields in __descending__ order and name in __ascending__ 87 | 88 | Also, a DRF-style sorting is supported. By default it uses ``o`` parameter from request query string. ``sorting_parameter_name`` property controls what parameter to use for sorting. 89 | Lookups are working in the django-filters style, like ``property_1__property_2`` (which will use object's ``property_1`` and, in turn, its ``property_2`` as key argument to ``sorted()``) 90 | Sorting is also possible by several fields. Sorting field have to be split with commas for that. Could be passed either via ``sorting_parameter_name`` in request parameters, or via view property. 91 | 92 | **WARNING:** the field chosen for ordering must be shared by all models/serializers in your ``querylist``. Any attempt to sort objects along non_shared fields will throw a ``KeyError``. 93 | 94 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. DjangoRestMultipleModels documentation master file, created by 2 | sphinx-quickstart on Thu Jun 9 15:00:02 2016. 3 | 4 | ======================== 5 | DjangoRestMultipleModels 6 | ======================== 7 | 8 | `Django Rest Framework `_ provides some incredible tools for serializing data, but sometimes you need to combine many serializers and/or models into a single API call. **drf-multiple-model** is an app designed to do just that. 9 | 10 | 11 | Installation 12 | ============ 13 | 14 | Install the package from pip:: 15 | 16 | pip install django-rest-multiple-models 17 | 18 | Make sure to add 'drf_multiple_model' to your INSTALLED_APPS:: 19 | 20 | INSTALLED_APPS = ( 21 | .... 22 | 'drf_multiple_model', 23 | ) 24 | 25 | Then simply import the view into any views.py in which you'd want to use it:: 26 | 27 | from drf_multiple_model.views import ObjectMultipleModelAPIView 28 | 29 | **Note:** This package is built on top of Django Rest Framework's generic views and serializers, so it presupposes that Django Rest Framework is installed and added to your project as well. 30 | 31 | 32 | Contents: 33 | 34 | .. toctree:: 35 | :maxdepth: 2 36 | 37 | basic-usage 38 | installation 39 | object-options 40 | flat-options 41 | filtering 42 | pagination 43 | viewsets 44 | one-to-two 45 | release-notes 46 | acknowledgments 47 | 48 | -------------------------------------------------------------------------------- /docs/installation.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | Installation 3 | ============ 4 | 5 | Install the package from pip:: 6 | 7 | pip install django-rest-multiple-models 8 | 9 | Make sure to add 'drf_multiple_model' to your INSTALLED_APPS:: 10 | 11 | INSTALLED_APPS = ( 12 | .... 13 | 'drf_multiple_model', 14 | ) 15 | 16 | Then simply import the view into any views.py in which you'd want to use it:: 17 | 18 | from drf_multiple_model.views import ObjectMultipleModelAPIView 19 | 20 | **Note:** This package is built on top of Django Rest Framework's generic views and serializers, so it presupposes that Django Rest Framework is installed and added to your project as well. 21 | 22 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | REM Command file for Sphinx documentation 4 | 5 | if "%SPHINXBUILD%" == "" ( 6 | set SPHINXBUILD=sphinx-build 7 | ) 8 | set BUILDDIR=_build 9 | set ALLSPHINXOPTS=-d %BUILDDIR%/doctrees %SPHINXOPTS% . 10 | set I18NSPHINXOPTS=%SPHINXOPTS% . 11 | if NOT "%PAPER%" == "" ( 12 | set ALLSPHINXOPTS=-D latex_paper_size=%PAPER% %ALLSPHINXOPTS% 13 | set I18NSPHINXOPTS=-D latex_paper_size=%PAPER% %I18NSPHINXOPTS% 14 | ) 15 | 16 | if "%1" == "" goto help 17 | 18 | if "%1" == "help" ( 19 | :help 20 | echo.Please use `make ^` where ^ is one of 21 | echo. html to make standalone HTML files 22 | echo. dirhtml to make HTML files named index.html in directories 23 | echo. singlehtml to make a single large HTML file 24 | echo. pickle to make pickle files 25 | echo. json to make JSON files 26 | echo. htmlhelp to make HTML files and a HTML help project 27 | echo. qthelp to make HTML files and a qthelp project 28 | echo. devhelp to make HTML files and a Devhelp project 29 | echo. epub to make an epub 30 | echo. epub3 to make an epub3 31 | echo. latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter 32 | echo. text to make text files 33 | echo. man to make manual pages 34 | echo. texinfo to make Texinfo files 35 | echo. gettext to make PO message catalogs 36 | echo. changes to make an overview over all changed/added/deprecated items 37 | echo. xml to make Docutils-native XML files 38 | echo. pseudoxml to make pseudoxml-XML files for display purposes 39 | echo. linkcheck to check all external links for integrity 40 | echo. doctest to run all doctests embedded in the documentation if enabled 41 | echo. coverage to run coverage check of the documentation if enabled 42 | echo. dummy to check syntax errors of document sources 43 | goto end 44 | ) 45 | 46 | if "%1" == "clean" ( 47 | for /d %%i in (%BUILDDIR%\*) do rmdir /q /s %%i 48 | del /q /s %BUILDDIR%\* 49 | goto end 50 | ) 51 | 52 | 53 | REM Check if sphinx-build is available and fallback to Python version if any 54 | %SPHINXBUILD% 1>NUL 2>NUL 55 | if errorlevel 9009 goto sphinx_python 56 | goto sphinx_ok 57 | 58 | :sphinx_python 59 | 60 | set SPHINXBUILD=python -m sphinx.__init__ 61 | %SPHINXBUILD% 2> nul 62 | if errorlevel 9009 ( 63 | echo. 64 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 65 | echo.installed, then set the SPHINXBUILD environment variable to point 66 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 67 | echo.may add the Sphinx directory to PATH. 68 | echo. 69 | echo.If you don't have Sphinx installed, grab it from 70 | echo.http://sphinx-doc.org/ 71 | exit /b 1 72 | ) 73 | 74 | :sphinx_ok 75 | 76 | 77 | if "%1" == "html" ( 78 | %SPHINXBUILD% -b html %ALLSPHINXOPTS% %BUILDDIR%/html 79 | if errorlevel 1 exit /b 1 80 | echo. 81 | echo.Build finished. The HTML pages are in %BUILDDIR%/html. 82 | goto end 83 | ) 84 | 85 | if "%1" == "dirhtml" ( 86 | %SPHINXBUILD% -b dirhtml %ALLSPHINXOPTS% %BUILDDIR%/dirhtml 87 | if errorlevel 1 exit /b 1 88 | echo. 89 | echo.Build finished. The HTML pages are in %BUILDDIR%/dirhtml. 90 | goto end 91 | ) 92 | 93 | if "%1" == "singlehtml" ( 94 | %SPHINXBUILD% -b singlehtml %ALLSPHINXOPTS% %BUILDDIR%/singlehtml 95 | if errorlevel 1 exit /b 1 96 | echo. 97 | echo.Build finished. The HTML pages are in %BUILDDIR%/singlehtml. 98 | goto end 99 | ) 100 | 101 | if "%1" == "pickle" ( 102 | %SPHINXBUILD% -b pickle %ALLSPHINXOPTS% %BUILDDIR%/pickle 103 | if errorlevel 1 exit /b 1 104 | echo. 105 | echo.Build finished; now you can process the pickle files. 106 | goto end 107 | ) 108 | 109 | if "%1" == "json" ( 110 | %SPHINXBUILD% -b json %ALLSPHINXOPTS% %BUILDDIR%/json 111 | if errorlevel 1 exit /b 1 112 | echo. 113 | echo.Build finished; now you can process the JSON files. 114 | goto end 115 | ) 116 | 117 | if "%1" == "htmlhelp" ( 118 | %SPHINXBUILD% -b htmlhelp %ALLSPHINXOPTS% %BUILDDIR%/htmlhelp 119 | if errorlevel 1 exit /b 1 120 | echo. 121 | echo.Build finished; now you can run HTML Help Workshop with the ^ 122 | .hhp project file in %BUILDDIR%/htmlhelp. 123 | goto end 124 | ) 125 | 126 | if "%1" == "qthelp" ( 127 | %SPHINXBUILD% -b qthelp %ALLSPHINXOPTS% %BUILDDIR%/qthelp 128 | if errorlevel 1 exit /b 1 129 | echo. 130 | echo.Build finished; now you can run "qcollectiongenerator" with the ^ 131 | .qhcp project file in %BUILDDIR%/qthelp, like this: 132 | echo.^> qcollectiongenerator %BUILDDIR%\qthelp\DjangoRestMultipleModels.qhcp 133 | echo.To view the help file: 134 | echo.^> assistant -collectionFile %BUILDDIR%\qthelp\DjangoRestMultipleModels.ghc 135 | goto end 136 | ) 137 | 138 | if "%1" == "devhelp" ( 139 | %SPHINXBUILD% -b devhelp %ALLSPHINXOPTS% %BUILDDIR%/devhelp 140 | if errorlevel 1 exit /b 1 141 | echo. 142 | echo.Build finished. 143 | goto end 144 | ) 145 | 146 | if "%1" == "epub" ( 147 | %SPHINXBUILD% -b epub %ALLSPHINXOPTS% %BUILDDIR%/epub 148 | if errorlevel 1 exit /b 1 149 | echo. 150 | echo.Build finished. The epub file is in %BUILDDIR%/epub. 151 | goto end 152 | ) 153 | 154 | if "%1" == "epub3" ( 155 | %SPHINXBUILD% -b epub3 %ALLSPHINXOPTS% %BUILDDIR%/epub3 156 | if errorlevel 1 exit /b 1 157 | echo. 158 | echo.Build finished. The epub3 file is in %BUILDDIR%/epub3. 159 | goto end 160 | ) 161 | 162 | if "%1" == "latex" ( 163 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 164 | if errorlevel 1 exit /b 1 165 | echo. 166 | echo.Build finished; the LaTeX files are in %BUILDDIR%/latex. 167 | goto end 168 | ) 169 | 170 | if "%1" == "latexpdf" ( 171 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 172 | cd %BUILDDIR%/latex 173 | make all-pdf 174 | cd %~dp0 175 | echo. 176 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 177 | goto end 178 | ) 179 | 180 | if "%1" == "latexpdfja" ( 181 | %SPHINXBUILD% -b latex %ALLSPHINXOPTS% %BUILDDIR%/latex 182 | cd %BUILDDIR%/latex 183 | make all-pdf-ja 184 | cd %~dp0 185 | echo. 186 | echo.Build finished; the PDF files are in %BUILDDIR%/latex. 187 | goto end 188 | ) 189 | 190 | if "%1" == "text" ( 191 | %SPHINXBUILD% -b text %ALLSPHINXOPTS% %BUILDDIR%/text 192 | if errorlevel 1 exit /b 1 193 | echo. 194 | echo.Build finished. The text files are in %BUILDDIR%/text. 195 | goto end 196 | ) 197 | 198 | if "%1" == "man" ( 199 | %SPHINXBUILD% -b man %ALLSPHINXOPTS% %BUILDDIR%/man 200 | if errorlevel 1 exit /b 1 201 | echo. 202 | echo.Build finished. The manual pages are in %BUILDDIR%/man. 203 | goto end 204 | ) 205 | 206 | if "%1" == "texinfo" ( 207 | %SPHINXBUILD% -b texinfo %ALLSPHINXOPTS% %BUILDDIR%/texinfo 208 | if errorlevel 1 exit /b 1 209 | echo. 210 | echo.Build finished. The Texinfo files are in %BUILDDIR%/texinfo. 211 | goto end 212 | ) 213 | 214 | if "%1" == "gettext" ( 215 | %SPHINXBUILD% -b gettext %I18NSPHINXOPTS% %BUILDDIR%/locale 216 | if errorlevel 1 exit /b 1 217 | echo. 218 | echo.Build finished. The message catalogs are in %BUILDDIR%/locale. 219 | goto end 220 | ) 221 | 222 | if "%1" == "changes" ( 223 | %SPHINXBUILD% -b changes %ALLSPHINXOPTS% %BUILDDIR%/changes 224 | if errorlevel 1 exit /b 1 225 | echo. 226 | echo.The overview file is in %BUILDDIR%/changes. 227 | goto end 228 | ) 229 | 230 | if "%1" == "linkcheck" ( 231 | %SPHINXBUILD% -b linkcheck %ALLSPHINXOPTS% %BUILDDIR%/linkcheck 232 | if errorlevel 1 exit /b 1 233 | echo. 234 | echo.Link check complete; look for any errors in the above output ^ 235 | or in %BUILDDIR%/linkcheck/output.txt. 236 | goto end 237 | ) 238 | 239 | if "%1" == "doctest" ( 240 | %SPHINXBUILD% -b doctest %ALLSPHINXOPTS% %BUILDDIR%/doctest 241 | if errorlevel 1 exit /b 1 242 | echo. 243 | echo.Testing of doctests in the sources finished, look at the ^ 244 | results in %BUILDDIR%/doctest/output.txt. 245 | goto end 246 | ) 247 | 248 | if "%1" == "coverage" ( 249 | %SPHINXBUILD% -b coverage %ALLSPHINXOPTS% %BUILDDIR%/coverage 250 | if errorlevel 1 exit /b 1 251 | echo. 252 | echo.Testing of coverage in the sources finished, look at the ^ 253 | results in %BUILDDIR%/coverage/python.txt. 254 | goto end 255 | ) 256 | 257 | if "%1" == "xml" ( 258 | %SPHINXBUILD% -b xml %ALLSPHINXOPTS% %BUILDDIR%/xml 259 | if errorlevel 1 exit /b 1 260 | echo. 261 | echo.Build finished. The XML files are in %BUILDDIR%/xml. 262 | goto end 263 | ) 264 | 265 | if "%1" == "pseudoxml" ( 266 | %SPHINXBUILD% -b pseudoxml %ALLSPHINXOPTS% %BUILDDIR%/pseudoxml 267 | if errorlevel 1 exit /b 1 268 | echo. 269 | echo.Build finished. The pseudo-XML files are in %BUILDDIR%/pseudoxml. 270 | goto end 271 | ) 272 | 273 | if "%1" == "dummy" ( 274 | %SPHINXBUILD% -b dummy %ALLSPHINXOPTS% %BUILDDIR%/dummy 275 | if errorlevel 1 exit /b 1 276 | echo. 277 | echo.Build finished. Dummy builder generates no files. 278 | goto end 279 | ) 280 | 281 | :end 282 | -------------------------------------------------------------------------------- /docs/object-options.rst: -------------------------------------------------------------------------------- 1 | ================================== 2 | ObjectMultipleModelAPIView Options 3 | ================================== 4 | 5 | Labels 6 | ====== 7 | 8 | By default, ``ObjectMultipleModelAPIView`` uses the model name as a label. If you want to use a custom label, you can add a ``label`` key to your ``queryset`` dicts, like so:: 9 | 10 | from drf_multiple_model.views import ObjectMultipleModelAPIView 11 | 12 | class TextAPIView(ObjectMultipleModelAPIView): 13 | querylist = [ 14 | { 15 | 'queryset': Play.objects.all(), 16 | 'serializer_class': PlaySerializer, 17 | 'label': 'drama', 18 | }, 19 | { 20 | 'queryset': Poem.objects.filter(style='Sonnet'), 21 | 'serializer_class': PoemSerializer, 22 | 'label': 'sonnets' 23 | }, 24 | .... 25 | ] 26 | 27 | which would return:: 28 | 29 | { 30 | 'drama': [ 31 | {'genre': 'Comedy', 'title': "A Midsummer Night's Dream", 'pages': 350}, 32 | {'genre': 'Tragedy', 'title': "Romeo and Juliet", 'pages': 300}, 33 | .... 34 | ], 35 | 'sonnets':[ 36 | {'title': 'Shall I compare thee to a summer's day?', 'stanzas': 1}, 37 | {'title': 'As a decrepit father takes delight', 'stanzas': 1}, 38 | .... 39 | ], 40 | } 41 | 42 | -------------------------------------------------------------------------------- /docs/one-to-two.rst: -------------------------------------------------------------------------------- 1 | ========================= 2 | Upgrading from 1.x to 2.0 3 | ========================= 4 | 5 | **drf_multiple_model** went through a substantial re-write from 1.x to 2.0. Not only did much of the underlying code get re-structured and streamlined, but the classes and API changed as well. Here are some of the biggest changes developers need to be aware of. 6 | 7 | views/mixins split in two 8 | ========================= 9 | 10 | Earlier iterations of **drf_multiple_model** tried to shoehorn many different formats and functionalities into a single view/mixin. This was making development increasingly difficult, as potentially problematic interactions grew expenentionally with the number of competing options. Instead of the the single ``MultipleModelAPIView``, you should use the following views: 11 | 12 | 1. If your 1.x view had ``flat = True``, you should use the ``FlatMultipleModelAPIView`` 13 | 2. If your 1.x view had ``objectify = True``, you should use the ``ObjectMultipleModelAPIView`` 14 | 3. If your 1.x view had both ``flat = True`` and ``objectify = True``, your view was broken and likely raised an Exception. Use one of the options above. 15 | 4. If your 1.x view had neither ``flat = True`` nor ``objectify = True``, you should reconsider and use one of the options above. The previously default response structure of ``list(dict(list( ... )`` made no sense, was overly complicated to consume, and has been removed from v2.0. 16 | 17 | querylist is no longer camelCased 18 | ================================= 19 | 20 | The bizarrely camelCased ``queryList`` field has been renamed the much more pythonic ``querylist`` 21 | 22 | querylist items are now dicts, not lists/tuples 23 | =============================================== 24 | 25 | If your 1.x querylist looked like this:: 26 | 27 | queryList = ( 28 | (Poem.objects.all(), PoemSerializer), 29 | (Play.objects.all(), PlaySerializer), 30 | ) 31 | 32 | your 2.0 querlist should look like this:: 33 | 34 | querylist = ( 35 | {'queryset': Poem.objects.all(), 'serializer_class': PoemSerializer}, 36 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 37 | ) 38 | 39 | Although this structure is slightly more verbose, is **much** more extensible. Consider, for example, what was needed previously in order to add a per-queryset filter function:: 40 | 41 | from drf_multiple_model.views import MultipleModelAPIView 42 | from drf_multiple_model.mixins import Query 43 | 44 | def my_custom_filter_fn(queryset, request, *args, **kwargs): 45 | .... 46 | 47 | class FilterFnView(MultipleModelAPIView): 48 | queryList = ( 49 | Query(Play.objects.all(), PlaySerializer, filter_fn=my_custom_filter_Fn), 50 | (Poem.objects.all(), PoemSerializer), 51 | ) 52 | 53 | This requires importing a special ``Query`` item, and confusingly mixing types (``Query`` object and ``tuple``) in the querylist. With the ``dict`` querylist structure, any number of extra parameters can be added simply by adding an extra key:: 54 | 55 | querylist = ( 56 | {'queryset': Poem.objects.all(), 'serializer_class': PoemSerializer, 'filter_fn': my_custom_filter_fn}, 57 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 58 | ) 59 | 60 | pagination uses custom-built paginators 61 | ======================================= 62 | 63 | Pagination in 1.x used the built in **rest_framework** paginators, but didn't actually restricted the items being queried; it simply formated the data **after** it had been fetched to remove extra items. Pagination has been re-written to only query the items request in 2.0, but this means paginators had to be re-written/extended to properly handle multiple querysets. As such, you can longer simply drop in **rest_framework** paginators and should only use the pagination available in ``drf_multiple_model.pagination``. See :ref:`pagination` for more details. 64 | -------------------------------------------------------------------------------- /docs/pagination.rst: -------------------------------------------------------------------------------- 1 | ========== 2 | Pagination 3 | ========== 4 | 5 | Because Django and Rest Framework's paginators are designed to work with a single model/queryset, they cannot simply be dropped into a ``MultipleModelAPIView`` and function properly. Currently, only **Limit/Offset** pagination has been ported to **drf_mutliple_model**, although other ``rest_framework`` paginators may be ported in the future. 6 | 7 | .. _pagination: 8 | 9 | Limit/Offset Pagination 10 | ======================= 11 | 12 | Limit/Offset functions very similarly to (and with the same query parameters as) `Rest Framework's LimitOffsetPagination <(http://www.django-rest-framework.org/api-guide/pagination/#limitoffsetpagination)>`_, but formatted to handle multiple models:: 13 | 14 | from drf_multiple_model.views import ObjectMultipleModelAPIView 15 | from drf_multiple_model.pagination import MultipleModelLimitOffsetPagination 16 | 17 | class LimitPagination(MultipleModelLimitOffsetPagination): 18 | default_limit = 2 19 | 20 | 21 | class ObjectLimitPaginationView(ObjectMultipleModelAPIView): 22 | pagination_class = LimitPagination 23 | querylist = ( 24 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 25 | {'queryset': Poem.objects.all(), 'serializer_class': PoemSerializer}, 26 | ) 27 | 28 | which would return:: 29 | 30 | { 31 | 'highest_count': 4, # Play model has four objects in the database 32 | 'overall_total': 7, # 4 Plays + 3 Poems 33 | 'next': 'http://yourserver/yourUrl/?page=2', 34 | 'previous': None, 35 | 'results': 36 | { 37 | 'Play': [ 38 | {'genre': 'Comedy', 'title': "A Midsummer Night's Dream", 'pages': 350}, 39 | {'genre': 'Tragedy', 'title': "Romeo and Juliet", 'pages': 300}, 40 | ], 41 | 'Poem': [ 42 | {'title': 'Shall I compare thee to a summer's day?', 'stanzas': 1}, 43 | {'title': 'As a decrepit father takes delight', 'stanzas': 1}, 44 | ], 45 | } 46 | } 47 | 48 | This would also work with the ``FlatMultipleModelAPIView`` (with caveats, see below):: 49 | 50 | class FlatLimitPaginationView(FlatMultipleModelAPIView): 51 | pagination_class = LimitPagination 52 | querylist = ( 53 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 54 | {'queryset': Poem.objects.all(), 'serializer_class': PoemSerializer}, 55 | ) 56 | 57 | which would return:: 58 | 59 | { 60 | 'highest_count': 4, # Play model has four objects in the database 61 | 'overall_total': 7, # 4 Plays + 3 Poems 62 | 'next': 'http://yourserver/yourUrl/?page=2', 63 | 'previous': None, 64 | 'results': 65 | [ 66 | {'genre': 'Comedy', 'title': "A Midsummer Night's Dream", 'pages': 350}, 67 | {'genre': 'Tragedy', 'title': "Romeo and Juliet", 'pages': 300}, 68 | {'title': 'Shall I compare thee to a summer's day?', 'stanzas': 1}, 69 | {'title': 'As a decrepit father takes delight', 'stanzas': 1} 70 | ] 71 | } 72 | 73 | .. warning:: 74 | Important ``FlatMultipleModel`` caveats below! 75 | 76 | The ``limit`` in LimitOffsetPagination is applied **per queryset**. This means that the number of results returned is actually *number_of_querylist_items* * *limit*. This is intuitive for the ``ObjectMultipleModelAPIView``, but the ``FlatMultipleModelAPIView`` may confuse some developers at first when a view with a limit of 50 and three different model/serializer combinations in the ``querylist`` returns a list of 150 items. 77 | 78 | The other thing to note about ``MultipleModelLimitOffsetPagination`` and ``FlatMultipleModelAPIView`` is that sorting is done **after** the querylists have been filter by the limit/offset pair. To understand why this may return some internal results, imagine a project ``ModalA``, which has 50 rows whose ``name`` field all start with 'A', and ModelB, which has 50 rows whose ``name`` field all start with 'B'. If limit/offset pagination with a limit of 10 is used in a view that sorts by ``name``, the first page will return 10 results with names that start with 'A' followed by 10 results that start with 'B'. The second page with then **also** contain 10 results that start with 'A' followed by 10 results that start with 'B', which certainly won't map onto a users expectation of alphabetical sorting. Unfortunately, sorting before fetching the data would likely require bypassing Django's querysets entirely and writing raw SQL with a join on the ``sorting_field`` field, which would be difficult to integrate cleanly into the current system. It is therefore recommended that when using ``MultipleModelLimitOffsetPagination`` that ``sorting_field`` values by hidden fields like ``id`` that won't be visible to the end user. 79 | -------------------------------------------------------------------------------- /docs/release-notes.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | Release Notes 3 | ============= 4 | 5 | 2.0 (2018-01-18) 6 | ================ 7 | 8 | * Refactored underlying code structure and API. Changes include: 9 | 10 | * Removed the nonsensical camelCase from querylist 11 | * Changing querylist items from lists/tupes to dicts (for more parameter flexibility). Eliminated the underlying ``Query`` model as a result. 12 | * Breaking the mixin into two separate mixins: ``ObjectMultipleModelMixing`` and ``FlatMultipleModelMixin``, as well as their respective views and viewsets 13 | * Removing the previously default response structure of ``list(dict(list( ... )`` 14 | 15 | * Adding limit/offset pagination that actually only queries the items it fetches (rather than iterating the whole queryset) 16 | * Removing pagination functionality from the ``FlatMultipleModelMixin`` and adding it to the ``ObjectMultipleModelMixin`` 17 | 18 | 1.8.1 (2017-12-20) 19 | ================== 20 | 21 | * Dropped support for Django 1.8 and 1.9 (in keeping with Django Rest Framework's support) 22 | * Expanded test coverage for Django 1.11 and Django 2.0 23 | 24 | 1.8 (2016-09-04) 25 | ================ 26 | 27 | * Added ``objectify`` property to return JSON object instead of an array (implemented by @ELIYAHUT123) 28 | * Added ``MultipleModelAPIViewSet`` for working with Viewsets (credit to Mike Hwang (@mehwang) for working out the implementation) 29 | * implemented tox for simultaneous testing of all relevant python/django combos 30 | * dropped support for Django 1.7 (based on Django Rest Frameworks's concurrent lack of support) 31 | 32 | 1.7 (2016-06-09) 33 | ================ 34 | 35 | * Expanded documentation 36 | * Moved to sphynx docs/readthedocs.org 37 | * Moved data formatting to ``format_data()`` function to allow for custom post-serialization data handling 38 | 39 | 1.6 (2016-02-23) 40 | ================ 41 | 42 | * Incorporated and expanded on reverse sort implemented by @schweickism 43 | 44 | 1.5 (2016-01-28) 45 | ================ 46 | 47 | * Added support for Django Rest Framework's pagination classes 48 | * Custom filter functions (implemented by @Symmetric) 49 | * Created Query class for handling queryList elements (implemented by @Symmetric) 50 | 51 | 1.3 (2015-12-10) 52 | ================ 53 | 54 | * Improper context passing bug fixed by @rbreu 55 | 56 | 1.2 (2015-11-11) 57 | ================ 58 | 59 | * Fixed a bug with the Browsable API when using Django Rest Framework >= 3.3 60 | 61 | 1.1 (2015-07-06) 62 | ================ 63 | 64 | * Added ``get_queryList()`` function to support creation of dynamic queryLists 65 | 66 | 1.0 (2015-06-29) 67 | ================ 68 | 69 | * initial release 70 | -------------------------------------------------------------------------------- /docs/viewsets.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | ViewSets 3 | ======== 4 | 5 | For use with ViewSets and Routers, **drf-multiple-model** provides the ``ObjectMultipleModelAPIViewSet`` and ``FlatMultipleModelAPIViewSet``. A simple configuration for using the provided ViewSets might look like:: 6 | 7 | from rest_framework import routers 8 | 9 | from drf_multiple_model.viewsets import ObjectMultipleModelAPIViewSet 10 | 11 | class TextAPIView(ObjectMultipleModelAPIViewSet): 12 | querylist = [ 13 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 14 | {'queryset': Poem.objects.filter(style='Sonnet'), 'serializer_class': PoemSerializer}, 15 | .... 16 | ] 17 | 18 | router = routers.SimpleRouter() 19 | router.register('texts', TextAPIView, basename='texts') 20 | 21 | 22 | **WARNING:** Because the ObjectMultipleModel views do not provide the ``queryset`` property, you **must** specify the ``basename`` property when you register a ``ObjectMultipleModelAPIViewSet`` with a router. 23 | 24 | The ``ObjectMultipleModelAPIViewSet`` has all the same configuration options as the ``ObjectMultipleModelAPIView`` object. For more information, see the :doc:`basic usage ` section. 25 | -------------------------------------------------------------------------------- /drf_multiple_model/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '2.1.3' 2 | __author__ = 'Matt Nishi-Broach' 3 | __license__ = 'MIT' 4 | __copyright__ = 'Copyright 2015-2018 Matt Nishi-Broach' 5 | 6 | # Version synonym 7 | VERSION = __version__ 8 | -------------------------------------------------------------------------------- /drf_multiple_model/mixins.py: -------------------------------------------------------------------------------- 1 | import warnings 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.db.models.query import QuerySet 5 | from rest_framework.response import Response 6 | 7 | 8 | class BaseMultipleModelMixin(object): 9 | """ 10 | Base class that holds functions need for all MultipleModelMixins/Views 11 | """ 12 | querylist = None 13 | 14 | # Keys required for every item in a querylist 15 | required_keys = ['queryset', 'serializer_class'] 16 | 17 | # default pagination state. Gets overridden if pagination is active 18 | is_paginated = False 19 | 20 | def get_querylist(self): 21 | assert self.querylist is not None, ( 22 | '{} should either include a `querylist` attribute, ' 23 | 'or override the `get_querylist()` method.'.format( 24 | self.__class__.__name__ 25 | ) 26 | ) 27 | 28 | return self.querylist 29 | 30 | def check_query_data(self, query_data): 31 | """ 32 | All items in a `querylist` must at least have `queryset` key and a 33 | `serializer_class` key. Any querylist item lacking both those keys 34 | will raise a ValidationError 35 | """ 36 | for key in self.required_keys: 37 | if key not in query_data: 38 | raise ValidationError( 39 | 'All items in the {} querylist attribute should contain a ' 40 | '`{}` key'.format(self.__class__.__name__, key) 41 | ) 42 | 43 | def load_queryset(self, query_data, request, *args, **kwargs): 44 | """ 45 | Fetches the queryset and runs any necessary filtering, both 46 | built-in rest_framework filters and custom filters passed into 47 | the querylist 48 | """ 49 | queryset = query_data.get('queryset', []) 50 | 51 | if isinstance(queryset, QuerySet): 52 | # Ensure queryset is re-evaluated on each request. 53 | queryset = queryset.all() 54 | 55 | # run rest_framework filters 56 | queryset = self.filter_queryset(queryset) 57 | 58 | # run custom filters 59 | filter_fn = query_data.get('filter_fn', None) 60 | if filter_fn is not None: 61 | queryset = filter_fn(queryset, request, *args, **kwargs) 62 | 63 | page = self.paginate_queryset(queryset) 64 | self.is_paginated = page is not None 65 | 66 | return page if page is not None else queryset 67 | 68 | def get_empty_results(self): 69 | """ 70 | Because the base result type is different depending on the return structure 71 | (e.g. list for flat, dict for object), `get_result_type` initials the 72 | `results` variable to the proper type 73 | """ 74 | assert self.result_type is not None, ( 75 | '{} must specify a `result_type` value or overwrite the ' 76 | '`get_empty_result` method.'.format(self.__class__.__name__) 77 | ) 78 | 79 | return self.result_type() 80 | 81 | def add_to_results(self, data, label, results): 82 | """ 83 | responsible for updating the running `results` variable with the 84 | data from this queryset/serializer combo 85 | """ 86 | raise NotImplementedError( 87 | '{} must specify how to add data to the running results tally ' 88 | 'by overriding the `add_to_results` method.'.format( 89 | self.__class__.__name__ 90 | ) 91 | ) 92 | 93 | def format_results(self, results, request): 94 | """ 95 | hook for processing/formatting the entire returned data set, once 96 | the querylist has been evaluated 97 | """ 98 | return results 99 | 100 | def list(self, request, *args, **kwargs): 101 | querylist = self.get_querylist() 102 | 103 | results = self.get_empty_results() 104 | 105 | for query_data in querylist: 106 | self.check_query_data(query_data) 107 | 108 | queryset = self.load_queryset(query_data, request, *args, **kwargs) 109 | 110 | # Run the paired serializer 111 | context = self.get_serializer_context() 112 | data = query_data['serializer_class'](queryset, many=True, context=context).data 113 | 114 | label = self.get_label(queryset, query_data) 115 | 116 | # Add the serializer data to the running results tally 117 | results = self.add_to_results(data, label, results) 118 | 119 | formatted_results = self.format_results(results, request) 120 | 121 | if self.is_paginated: 122 | try: 123 | formatted_results = self.paginator.format_response(formatted_results) 124 | except AttributeError: 125 | raise NotImplementedError( 126 | "{} cannot use the regular Rest Framework or Django paginators as is. " 127 | "Use one of the included paginators from `drf_multiple_models.pagination " 128 | "or subclass a paginator to add the `format_response` method." 129 | "".format(self.__class__.__name__) 130 | ) 131 | 132 | return Response(formatted_results) 133 | 134 | 135 | class FlatMultipleModelMixin(BaseMultipleModelMixin): 136 | """ 137 | Create a List of objects from multiple models/serializers. 138 | 139 | Mixin is expecting the view will have a querylist variable, which is 140 | a list/tuple of dicts containing, at mininum, a `queryset` key and a 141 | `serializer_class` key, as below: 142 | 143 | queryList = [ 144 | {'queryset': MyModalA.objects.all(), 'serializer_class': MyModelASerializer ), 145 | {'queryset': MyModalB.objects.all(), 'serializer_class': MyModelBSerializer ), 146 | {'queryset': MyModalC.objects.all(), 'serializer_class': MyModelCSerializer ), 147 | ..... 148 | ] 149 | 150 | This mixin returns a list of serialized data merged together in a single list, eg: 151 | 152 | [ 153 | { 'id': 1, 'type': 'myModelA', 'title': 'some_object' }, 154 | { 'id': 4, 'type': 'myModelB', 'title': 'some_other_object' }, 155 | { 'id': 8, 'type': 'myModelA', 'title': 'anotherother_object' }, 156 | ... 157 | ] 158 | """ 159 | # Optional keyword to sort flat lasts by given attribute 160 | # note that the attribute must by shared by ALL models 161 | sorting_field = None 162 | sorting_fields = None 163 | 164 | # A mapping, similar to Django's `OrderingFilter`. In the following format: {parameter name: result field name} 165 | # If request query param contains sorting parameter (by default - 'o'), result will be sorted by this parameter. 166 | # Django-like model lookups are supported via '__', but you have to be sure that all querysets will return results 167 | # with corresponding structure. 168 | sorting_fields_map = {} 169 | sorting_parameter_name = 'o' 170 | 171 | # Flag to append the particular django model being used to the data 172 | add_model_type = True 173 | 174 | result_type = list 175 | 176 | _list_attribute_error = 'Invalid sorting field. Corresponding data item is a list: {}' 177 | 178 | def initial(self, request, *args, **kwargs): 179 | """ 180 | Overrides DRF's `initial` in order to set the `_sorting_field` from corresponding property in view. 181 | Protected property is required in order to support overriding of `sorting_field` via `@property`, we do this 182 | after original `initial` has been ran in order to make sure that view has all its properties set up. 183 | """ 184 | super(FlatMultipleModelMixin, self).initial(request, *args, **kwargs) 185 | assert not (self.sorting_field and self.sorting_fields), \ 186 | '{} should either define ``sorting_field`` or ``sorting_fields`` property, not both.' \ 187 | .format(self.__class__.__name__) 188 | if self.sorting_field: 189 | warnings.warn( 190 | '``sorting_field`` property is pending its deprecation. Use ``sorting_fields`` instead.', 191 | DeprecationWarning 192 | ) 193 | self.sorting_fields = [self.sorting_field] 194 | self._sorting_fields = self.sorting_fields 195 | 196 | def get_label(self, queryset, query_data): 197 | """ 198 | Gets option label for each datum. Can be used for type identification 199 | of individual serialized objects 200 | """ 201 | if query_data.get('label', False): 202 | return query_data['label'] 203 | elif self.add_model_type: 204 | try: 205 | return queryset.model.__name__ 206 | except AttributeError: 207 | return query_data['queryset'].model.__name__ 208 | 209 | def add_to_results(self, data, label, results): 210 | """ 211 | Adds the label to the results, as needed, then appends the data 212 | to the running results tab 213 | """ 214 | for datum in data: 215 | if label is not None: 216 | datum.update({'type': label}) 217 | 218 | results.append(datum) 219 | 220 | return results 221 | 222 | def format_results(self, results, request): 223 | """ 224 | Prepares sorting parameters, and sorts results, if(as) necessary 225 | """ 226 | self.prepare_sorting_fields() 227 | if self._sorting_fields: 228 | results = self.sort_results(results) 229 | 230 | if request.accepted_renderer.format == 'html': 231 | # Makes the the results available to the template context by transforming to a dict 232 | results = {'data': results} 233 | 234 | return results 235 | 236 | def _sort_by(self, datum, param, path=None): 237 | """ 238 | Key function that is used for results sorting. This is passed as argument to `sorted()` 239 | """ 240 | if not path: 241 | path = [] 242 | try: 243 | if '__' in param: 244 | root, new_param = param.split('__') 245 | path.append(root) 246 | return self._sort_by(datum[root], param=new_param, path=path) 247 | else: 248 | path.append(param) 249 | 250 | data = datum[param] 251 | if isinstance(data, list): 252 | raise ValidationError(self._list_attribute_error.format(param)) 253 | return data 254 | except TypeError: 255 | raise ValidationError(self._list_attribute_error.format('.'.join(path))) 256 | except KeyError: 257 | raise ValidationError('Invalid sorting field: {}'.format('.'.join(path))) 258 | 259 | def prepare_sorting_fields(self): 260 | """ 261 | Determine sorting direction and sorting field based on request query parameters and sorting options 262 | of self 263 | """ 264 | if self.sorting_parameter_name in self.request.query_params: 265 | # Extract sorting parameter from query string 266 | self._sorting_fields = [ 267 | _.strip() for _ in self.request.query_params.get(self.sorting_parameter_name).split(',') 268 | ] 269 | 270 | if self._sorting_fields: 271 | # Create a list of sorting parameters. Each parameter is a tuple: (field:str, descending:bool) 272 | self._sorting_fields = [ 273 | (self.sorting_fields_map.get(field.lstrip('-'), field.lstrip('-')), field[0] == '-') 274 | for field in self._sorting_fields 275 | ] 276 | 277 | def sort_results(self, results): 278 | for field, descending in reversed(self._sorting_fields): 279 | results = sorted( 280 | results, 281 | reverse=descending, 282 | key=lambda x: self._sort_by(x, field) 283 | ) 284 | return results 285 | 286 | 287 | class ObjectMultipleModelMixin(BaseMultipleModelMixin): 288 | """ 289 | Create a Dictionary of objects from multiple models/serializers. 290 | 291 | Mixin is expecting the view will have a querylist variable, which is 292 | a list/tuple of dicts containing, at mininum, a `queryset` key and a 293 | `serializer_class` key, as below: 294 | 295 | queryList = [ 296 | {'queryset': MyModalA.objects.all(), 'serializer_class': MyModelASerializer ), 297 | {'queryset': MyModalB.objects.all(), 'serializer_class': MyModelBSerializer ), 298 | {'queryset': MyModalC.objects.all(), 'serializer_class': MyModelCSerializer ), 299 | ... 300 | ] 301 | 302 | This mixin returns a dictionary of serialized data separated by object type, e.g.: 303 | 304 | { 305 | 'MyModelA': [ 306 | { 'id': 1, 'type': 'myModelA', 'title': 'some_object' }, 307 | { 'id': 8, 'type': 'myModelA', 'title': 'anotherother_object' }, 308 | ... 309 | ], 310 | 'MyModelB': [ 311 | { 'id': 4, 'type': 'myModelB', 'title': 'some_other_object' }, 312 | ... 313 | ] 314 | ... 315 | } 316 | """ 317 | result_type = dict 318 | 319 | def add_to_results(self, data, label, results): 320 | results[label] = data 321 | 322 | return results 323 | 324 | def get_label(self, queryset, query_data): 325 | """ 326 | Gets option label for each datum. Can be used for type identification 327 | of individual serialized objects 328 | """ 329 | if query_data.get('label', False): 330 | return query_data['label'] 331 | 332 | try: 333 | return queryset.model.__name__ 334 | except AttributeError: 335 | return query_data['queryset'].model.__name__ 336 | -------------------------------------------------------------------------------- /drf_multiple_model/models.py: -------------------------------------------------------------------------------- 1 | # Just to make django happy 2 | -------------------------------------------------------------------------------- /drf_multiple_model/pagination.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from rest_framework.pagination import LimitOffsetPagination 4 | 5 | 6 | class MultipleModelLimitOffsetPagination(LimitOffsetPagination): 7 | """ 8 | An extentions of Rest Framework's limit/offset pagination 9 | to work with querylists. This mostly involves creating a running 10 | tally of the highest queryset `count`, rather than only referring 11 | to a single queryset's count 12 | """ 13 | def paginate_queryset(self, queryset, request, view=None): 14 | """ 15 | adds `max_count` as a running tally of the largest table size. Used for calculating 16 | next/previous links later 17 | """ 18 | result = super(MultipleModelLimitOffsetPagination, self).paginate_queryset(queryset, request, view) 19 | 20 | try: 21 | if self.max_count < self.count: 22 | self.max_count = self.count 23 | except AttributeError: 24 | self.max_count = self.count 25 | 26 | try: 27 | self.total += self.count 28 | except AttributeError: 29 | self.total = self.count 30 | 31 | return result 32 | 33 | def format_response(self, data): 34 | """ 35 | replaces the `count` (the last queryset count) with the running `max_count` variable, 36 | to ensure accurate link calculation 37 | """ 38 | self.count = self.max_count 39 | 40 | return OrderedDict([ 41 | ('highest_count', self.max_count), 42 | ('overall_total', self.total), 43 | ('next', self.get_next_link()), 44 | ('previous', self.get_previous_link()), 45 | ('results', data) 46 | ]) 47 | -------------------------------------------------------------------------------- /drf_multiple_model/views.py: -------------------------------------------------------------------------------- 1 | from drf_multiple_model.mixins import FlatMultipleModelMixin, ObjectMultipleModelMixin 2 | 3 | from rest_framework.generics import GenericAPIView 4 | 5 | 6 | class FlatMultipleModelAPIView(FlatMultipleModelMixin, GenericAPIView): 7 | def get(self, request, *args, **kwargs): 8 | return self.list(request, *args, **kwargs) 9 | 10 | def get_queryset(self): 11 | return None 12 | 13 | 14 | class ObjectMultipleModelAPIView(ObjectMultipleModelMixin, GenericAPIView): 15 | def get(self, request, *args, **kwargs): 16 | return self.list(request, *args, **kwargs) 17 | 18 | def get_queryset(self): 19 | return None 20 | -------------------------------------------------------------------------------- /drf_multiple_model/viewsets.py: -------------------------------------------------------------------------------- 1 | from rest_framework.viewsets import GenericViewSet 2 | 3 | from drf_multiple_model.mixins import FlatMultipleModelMixin, ObjectMultipleModelMixin 4 | 5 | 6 | class FlatMultipleModelAPIViewSet(FlatMultipleModelMixin, GenericViewSet): 7 | def get_queryset(self): 8 | return None 9 | 10 | 11 | class ObjectMultipleModelAPIViewSet(ObjectMultipleModelMixin, GenericViewSet): 12 | def get_queryset(self): 13 | return None 14 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tests.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | djangorestframework>=3.9 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | from setuptools import setup 4 | 5 | with open(os.path.join(os.path.dirname(__file__), 'PYPI_README.rst')) as readme: 6 | README = readme.read() 7 | 8 | # allow setup.py to be run from any path 9 | os.chdir(os.path.normpath(os.path.join(os.path.abspath(__file__), os.pardir))) 10 | 11 | 12 | def get_version(package): 13 | """ 14 | Return package version as listed in `__version__` in `init.py`. 15 | """ 16 | init_py = open(os.path.join(package, '__init__.py')).read() 17 | return re.search("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) 18 | 19 | 20 | version = get_version('drf_multiple_model') 21 | 22 | setup( 23 | name='django-rest-multiple-models', 24 | version=version, 25 | packages=['drf_multiple_model'], 26 | include_package_data=True, 27 | license='MIT License', 28 | description='Multiple model/queryset view (and mixin) for Django Rest Framework', 29 | long_description=README, 30 | url='https://github.com/Axiologue/DjangoRestMultipleModels', 31 | author='Matt Nishi-Broach', 32 | author_email='go.for.dover@gmail.com', 33 | classifiers=[ 34 | 'Environment :: Web Environment', 35 | 'Framework :: Django', 36 | 'Intended Audience :: Developers', 37 | 'License :: OSI Approved :: MIT License', 38 | 'Operating System :: OS Independent', 39 | 'Programming Language :: Python', 40 | 'Programming Language :: Python :: 2', 41 | 'Programming Language :: Python :: 2.7', 42 | 'Programming Language :: Python :: 3', 43 | 'Programming Language :: Python :: 3.4', 44 | 'Programming Language :: Python :: 3.5', 45 | 'Programming Language :: Python :: 3.6', 46 | 'Programming Language :: Python :: 3.7', 47 | 'Topic :: Internet :: WWW/HTTP', 48 | 'Topic :: Internet :: WWW/HTTP :: Dynamic Content', 49 | ], 50 | ) 51 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/MattBroach/DjangoRestMultipleModels/c24ad87e88938aa7a55d89d692f5bab08bebb761/tests/__init__.py -------------------------------------------------------------------------------- /tests/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Play(models.Model): 5 | genre = models.CharField(max_length=100) 6 | title = models.CharField(max_length=200) 7 | year = models.IntegerField() 8 | author = models.ForeignKey('tests.Author', related_name='plays', on_delete=models.CASCADE) 9 | 10 | 11 | class Poem(models.Model): 12 | title = models.CharField(max_length=200) 13 | style = models.CharField(max_length=100) 14 | author = models.ForeignKey('tests.Author', related_name='poems', on_delete=models.CASCADE) 15 | 16 | 17 | class Author(models.Model): 18 | name = models.CharField(max_length=100) 19 | -------------------------------------------------------------------------------- /tests/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from .models import Author, Play, Poem 4 | 5 | 6 | class AuthorSerializer(serializers.ModelSerializer): 7 | class Meta: 8 | model = Author 9 | fields = ('name',) 10 | 11 | 12 | class PlaySerializer(serializers.ModelSerializer): 13 | class Meta: 14 | model = Play 15 | fields = ('genre', 'title', 'year') 16 | 17 | 18 | class PoemSerializer(serializers.ModelSerializer): 19 | class Meta: 20 | model = Poem 21 | fields = ('title', 'style') 22 | 23 | 24 | class PlayWithAuthorSerializer(serializers.ModelSerializer): 25 | author = AuthorSerializer() 26 | 27 | class Meta: 28 | model = Play 29 | fields = ('genre', 'title', 'year', 'author') 30 | 31 | 32 | class PoemWithAuthorSerializer(serializers.ModelSerializer): 33 | author = AuthorSerializer() 34 | 35 | class Meta: 36 | model = Poem 37 | fields = ('title', 'style', 'author') 38 | 39 | 40 | class AuthorListSerializer(AuthorSerializer): 41 | plays = PlaySerializer(many=True) 42 | poems = PoemSerializer(many=True) 43 | 44 | class Meta: 45 | model = Author 46 | fields = AuthorSerializer.Meta.fields + ('plays', 'poems') 47 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 3 | 4 | SECRET_KEY = 'itsasecret' 5 | 6 | ALLOWED_HOSTS = [] 7 | 8 | INSTALLED_APPS = [ 9 | 'django.contrib.admin', 10 | 'django.contrib.auth', 11 | 'django.contrib.contenttypes', 12 | 'django.contrib.sessions', 13 | 'django.contrib.messages', 14 | 'django.contrib.staticfiles', 15 | 'rest_framework', 16 | 'drf_multiple_model', 17 | 'tests', 18 | ] 19 | 20 | MIDDLEWARE_CLASSES = [ 21 | 'django.contrib.sessions.middleware.SessionMiddleware', 22 | 'django.middleware.common.CommonMiddleware', 23 | 'django.middleware.csrf.CsrfViewMiddleware', 24 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 25 | 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 26 | 'django.contrib.messages.middleware.MessageMiddleware', 27 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 28 | ] 29 | 30 | TEMPLATES = [ 31 | { 32 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 33 | 'DIRS': [], 34 | 'APP_DIRS': True, 35 | 'OPTIONS': { 36 | 'context_processors': [ 37 | 'django.template.context_processors.debug', 38 | 'django.template.context_processors.request', 39 | 'django.contrib.auth.context_processors.auth', 40 | 'django.contrib.messages.context_processors.messages', 41 | ], 42 | }, 43 | }, 44 | ] 45 | 46 | 47 | # Database 48 | # https://docs.djangoproject.com/en/1.9/ref/settings/#databases 49 | 50 | DATABASES = { 51 | 'default': { 52 | 'ENGINE': 'django.db.backends.sqlite3', 53 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 54 | } 55 | } 56 | 57 | 58 | # Password validation 59 | # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators 60 | 61 | AUTH_PASSWORD_VALIDATORS = [ 62 | { 63 | 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 64 | }, 65 | { 66 | 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 67 | }, 68 | { 69 | 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 70 | }, 71 | { 72 | 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 73 | }, 74 | ] 75 | 76 | 77 | # Internationalization 78 | # https://docs.djangoproject.com/en/1.9/topics/i18n/ 79 | 80 | LANGUAGE_CODE = 'en-us' 81 | 82 | TIME_ZONE = 'UTC' 83 | 84 | USE_I18N = True 85 | 86 | USE_L10N = True 87 | 88 | USE_TZ = True 89 | 90 | 91 | # Static files (CSS, JavaScript, Images) 92 | # https://docs.djangoproject.com/en/1.9/howto/static-files/ 93 | 94 | STATIC_URL = '/static/' 95 | 96 | 97 | REST_FRAMEWORK = { 98 | 'TEST_REQUEST_RENDERER_CLASSES': ( 99 | 'rest_framework.renderers.MultiPartRenderer', 100 | 'rest_framework.renderers.JSONRenderer', 101 | 'rest_framework.renderers.TemplateHTMLRenderer' 102 | ) 103 | } 104 | -------------------------------------------------------------------------------- /tests/test_flat_view.py: -------------------------------------------------------------------------------- 1 | from django.test import override_settings 2 | from django.core.exceptions import ValidationError 3 | from django.conf.urls import url 4 | from django.core.cache import cache 5 | from rest_framework.test import APIClient, APIRequestFactory 6 | from rest_framework import status, filters 7 | 8 | from .utils import MultipleModelTestCase 9 | from .models import Play, Poem, Author 10 | from .serializers import PlaySerializer, PoemSerializer, PlayWithAuthorSerializer, PoemWithAuthorSerializer, \ 11 | AuthorListSerializer 12 | from drf_multiple_model.views import FlatMultipleModelAPIView 13 | 14 | factory = APIRequestFactory() 15 | 16 | 17 | # Regular Views 18 | class BasicFlatView(FlatMultipleModelAPIView): 19 | querylist = ( 20 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 21 | {'queryset': Poem.objects.filter(style="Sonnet"), 'serializer_class': PoemSerializer}, 22 | ) 23 | 24 | 25 | class SortedFlatView(BasicFlatView): 26 | sorting_field = 'title' 27 | 28 | 29 | class ReversedFlatView(BasicFlatView): 30 | sorting_field = '-title' 31 | 32 | 33 | class SortingFlatView(FlatMultipleModelAPIView): 34 | sorting_fields_map = {'author': 'author__name'} 35 | 36 | querylist = ( 37 | {'queryset': Play.objects.select_related('author'), 'serializer_class': PlayWithAuthorSerializer}, 38 | { 39 | 'queryset': Poem.objects.select_related('author').filter(style="Sonnet"), 40 | 'serializer_class': PoemWithAuthorSerializer 41 | }, 42 | ) 43 | 44 | 45 | class SortingMultipleFieldsFlatView(SortingFlatView): 46 | sorting_fields = ['type', 'title'] 47 | 48 | 49 | class ReversedSortingMultipleFieldsFlatView(SortingFlatView): 50 | sorting_fields = ['-type', 'title'] 51 | 52 | 53 | class SortingFlatViewListData(FlatMultipleModelAPIView): 54 | sorting_field = 'plays' 55 | querylist = ( 56 | {'queryset': Author.objects.prefetch_related('plays', 'poems'), 'serializer_class': AuthorListSerializer}, 57 | ) 58 | 59 | 60 | class CustomSortingParamFlatView(SortingFlatView): 61 | sorting_parameter_name = 'custom_o' 62 | 63 | 64 | class NoLabelView(BasicFlatView): 65 | add_model_type = False 66 | 67 | 68 | class CustomLabelView(FlatMultipleModelAPIView): 69 | querylist = ( 70 | { 71 | 'queryset': Play.objects.all(), 72 | 'serializer_class': PlaySerializer, 73 | 'label': 'Drama', 74 | }, 75 | { 76 | 'queryset': Poem.objects.filter(style="Sonnet"), 77 | 'serializer_class': PoemSerializer, 78 | 'label': 'Poetry', 79 | }, 80 | ) 81 | 82 | 83 | class DynamicQueryView(FlatMultipleModelAPIView): 84 | def get_querylist(self): 85 | title = self.kwargs['play'].replace('-', ' ') 86 | 87 | querylist = ( 88 | {'queryset': Play.objects.filter(title=title), 'serializer_class': PlaySerializer}, 89 | {'queryset': Poem.objects.filter(style="Sonnet"), 'serializer_class': PoemSerializer}, 90 | ) 91 | 92 | return querylist 93 | 94 | 95 | class SearchFilterView(BasicFlatView): 96 | filter_backends = (filters.SearchFilter,) 97 | search_fields = ('title',) 98 | 99 | 100 | # Testing filter_fn 101 | def title_without_letter(queryset, request, *args, **kwargs): 102 | letter_to_exclude = request.query_params['letter'] 103 | return queryset.exclude(title__icontains=letter_to_exclude) 104 | 105 | 106 | class FilterFnView(FlatMultipleModelAPIView): 107 | querylist = ( 108 | { 109 | 'queryset': Play.objects.all(), 110 | 'serializer_class': PlaySerializer, 111 | 'filter_fn': title_without_letter, 112 | }, 113 | { 114 | 'queryset': Poem.objects.filter(style="Sonnet"), 115 | 'serializer_class': PoemSerializer, 116 | }, 117 | ) 118 | 119 | 120 | class CachedQueryView(FlatMultipleModelAPIView): 121 | querylist = ( 122 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 123 | {'queryset': Poem.objects.filter(style="Sonnet"), 'serializer_class': PoemSerializer}, 124 | ) 125 | 126 | def load_queryset(self, query_data, request, *args, **kwargs): 127 | queryset = cache.get('{}-queryset'.format(query_data['queryset'].model.__name__)) 128 | if not queryset: 129 | queryset = query_data['queryset'].all() 130 | cache.set('{}-queryset'.format(query_data['queryset'].model.__name__), queryset) 131 | return queryset 132 | 133 | 134 | # Broken Views 135 | class NoQuerylistView(FlatMultipleModelAPIView): 136 | pass 137 | 138 | 139 | class NoQuerysetView(FlatMultipleModelAPIView): 140 | querylist = [ 141 | {'serializer_class': PlaySerializer}, 142 | {'serializer_class': PoemSerializer}, 143 | ] 144 | 145 | 146 | class NoSerializerClassView(FlatMultipleModelAPIView): 147 | querylist = [ 148 | {'queryset': Play.objects.all()}, 149 | {'queryset': Poem.objects.all()}, 150 | ] 151 | 152 | 153 | class WrongSortFieldView(BasicFlatView): 154 | sorting_field = 'year' 155 | 156 | 157 | urlpatterns = [ 158 | url(r'^$', BasicFlatView.as_view()), 159 | ] 160 | 161 | 162 | # TESTS 163 | @override_settings(ROOT_URLCONF=__name__) 164 | class TestMMFlatViews(MultipleModelTestCase): 165 | maxDiff = None 166 | sorted_results = [ 167 | {'genre': 'Comedy', 'title': 'A Midsummer Night\'s Dream', 'year': 1600, 'type': 'Play'}, 168 | {'genre': 'Comedy', 'title': 'As You Like It', 'year': 1623, 'type': 'Play'}, 169 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet', 'type': 'Poem'}, 170 | {'genre': 'Tragedy', 'title': 'Julius Caesar', 'year': 1623, 'type': 'Play'}, 171 | {'genre': 'Tragedy', 'title': 'Romeo And Juliet', 'year': 1597, 'type': 'Play'}, 172 | {'title': "Shall I compare thee to a summer's day?", 'style': 'Sonnet', 'type': 'Poem'}, 173 | ] 174 | sorted_results_w_author = [ 175 | {'genre': 'Tragedy', 'title': 'Romeo And Juliet', 'year': 1597, 'author': {'name': 'Play Shakespeare 1'}, 176 | 'type': 'Play'}, 177 | {'genre': 'Comedy', 'title': "A Midsummer Night's Dream", 'year': 1600, 178 | 'author': {'name': 'Play Shakespeare 2'}, 'type': 'Play'}, 179 | {'genre': 'Tragedy', 'title': 'Julius Caesar', 'year': 1623, 'author': {'name': 'Play Shakespeare 3'}, 180 | 'type': 'Play'}, 181 | {'genre': 'Comedy', 'title': 'As You Like It', 'year': 1623, 'author': {'name': 'Play Shakespeare 4'}, 182 | 'type': 'Play'}, 183 | {'title': "Shall I compare thee to a summer's day?", 'style': 'Sonnet', 184 | 'author': {'name': 'Poem Shakespeare 1'}, 'type': 'Poem'}, 185 | {'title': 'As a decrepit father takes delight', 'style': 'Sonnet', 186 | 'author': {'name': 'Poem Shakespeare 2'}, 'type': 'Poem'} 187 | ] 188 | unsorted_results = [ 189 | {'genre': 'Tragedy', 'title': 'Romeo And Juliet', 'year': 1597, 'type': 'Play'}, 190 | {'genre': 'Comedy', 'title': 'A Midsummer Night\'s Dream', 'year': 1600, 'type': 'Play'}, 191 | {'genre': 'Tragedy', 'title': 'Julius Caesar', 'year': 1623, 'type': 'Play'}, 192 | {'genre': 'Comedy', 'title': 'As You Like It', 'year': 1623, 'type': 'Play'}, 193 | {'title': "Shall I compare thee to a summer's day?", 'style': 'Sonnet', 'type': 'Poem'}, 194 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet', 'type': 'Poem'}, 195 | ] 196 | 197 | def test_post(self): 198 | """ 199 | POST requests should throw a 405 Error 200 | """ 201 | view = BasicFlatView.as_view() 202 | 203 | data = {'fake': 'data'} 204 | request = factory.post('/', data, format='json') 205 | 206 | with self.assertNumQueries(0): 207 | response = view(request).render() 208 | 209 | self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) 210 | self.assertEqual(response.data, {"detail": 'Method "POST" not allowed.'}) 211 | 212 | def test_put(self): 213 | """ 214 | PUT requests should throw a 405 Error 215 | """ 216 | view = BasicFlatView.as_view() 217 | 218 | data = {'fake': 'data'} 219 | request = factory.put('/', data, format='json') 220 | 221 | with self.assertNumQueries(0): 222 | response = view(request).render() 223 | 224 | self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) 225 | self.assertEqual(response.data, {"detail": 'Method "PUT" not allowed.'}) 226 | 227 | def test_delete(self): 228 | """ 229 | DELETE requests should throw a 405 Error 230 | """ 231 | view = BasicFlatView.as_view() 232 | 233 | request = factory.delete('/') 234 | 235 | with self.assertNumQueries(0): 236 | response = view(request).render() 237 | 238 | self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) 239 | self.assertEqual(response.data, {"detail": 'Method "DELETE" not allowed.'}) 240 | 241 | def test_no_querylist(self): 242 | """ 243 | A view with no querylist and no `get_querylist` overwrite should raise 244 | an assertion error with the appropriate message 245 | """ 246 | view = NoQuerylistView.as_view() 247 | 248 | request = factory.get('/') 249 | 250 | with self.assertRaises(AssertionError) as error: 251 | view(request).render() 252 | 253 | self.assertEqual(str(error.exception), ( 254 | 'NoQuerylistView should either include a `querylist` attribute, ' 255 | 'or override the `get_querylist()` method.' 256 | )) 257 | 258 | def test_no_queryset(self): 259 | """ 260 | A querylist with no `queryset` key should raise a ValidationError with the 261 | appropriate message 262 | """ 263 | view = NoQuerysetView.as_view() 264 | 265 | request = factory.get('/') 266 | 267 | with self.assertRaises(ValidationError) as error: 268 | view(request).render() 269 | 270 | self.assertEqual(error.exception.message, ( 271 | 'All items in the NoQuerysetView querylist attribute ' 272 | 'should contain a `queryset` key' 273 | )) 274 | 275 | def test_no_serializer_class(self): 276 | """ 277 | A querylist with no `serializer_class` key should raise a ValidationError with the 278 | appropriate message 279 | """ 280 | view = NoSerializerClassView.as_view() 281 | 282 | request = factory.get('/') 283 | 284 | with self.assertRaises(ValidationError) as error: 285 | view(request).render() 286 | 287 | self.assertEqual(error.exception.message, ( 288 | 'All items in the NoSerializerClassView querylist attribute ' 289 | 'should contain a `serializer_class` key' 290 | )) 291 | 292 | def test_basic_flat_view(self): 293 | """ 294 | The default setting for the `FlatMultipleModelView` should return 295 | the serialized objects in querylist order 296 | """ 297 | view = BasicFlatView.as_view() 298 | 299 | request = factory.get('/') 300 | with self.assertNumQueries(2): 301 | response = view(request).render() 302 | 303 | self.assertEqual(response.status_code, status.HTTP_200_OK) 304 | 305 | self.assertEqual(len(response.data), 6) 306 | self.assertEqual(response.data, self.unsorted_results) 307 | 308 | def test_no_label(self): 309 | """ 310 | Tests that no label (aka add_model_type = False) just gives the data 311 | """ 312 | view = NoLabelView.as_view() 313 | 314 | request = factory.get('/') 315 | with self.assertNumQueries(2): 316 | response = view(request).render() 317 | 318 | self.assertEqual(response.status_code, status.HTTP_200_OK) 319 | 320 | self.assertEqual(len(response.data), 6) 321 | self.assertEqual(response.data, [{k: v for k, v in i.items() if k != 'type'} for i in self.unsorted_results]) 322 | 323 | def test_new_labels(self): 324 | """ 325 | Adding the 'label' key to queryList elements should use those labels 326 | instead of the model names 327 | """ 328 | view = CustomLabelView.as_view() 329 | 330 | request = factory.get('/') 331 | with self.assertNumQueries(2): 332 | response = view(request).render() 333 | 334 | self.assertEqual(response.status_code, status.HTTP_200_OK) 335 | 336 | self.assertEqual(len(response.data), 6) 337 | self.assertEqual(response.data, [ 338 | {'genre': 'Tragedy', 'title': 'Romeo And Juliet', 'year': 1597, 'type': 'Drama'}, 339 | {'genre': 'Comedy', 'title': 'A Midsummer Night\'s Dream', 'year': 1600, 'type': 'Drama'}, 340 | {'genre': 'Tragedy', 'title': 'Julius Caesar', 'year': 1623, 'type': 'Drama'}, 341 | {'genre': 'Comedy', 'title': 'As You Like It', 'year': 1623, 'type': 'Drama'}, 342 | {'title': "Shall I compare thee to a summer's day?", 'style': 'Sonnet', 'type': 'Poetry'}, 343 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet', 'type': 'Poetry'}, 344 | ]) 345 | 346 | def test_filter_fn_view(self): 347 | """ 348 | The filter function is useful if you want to apply filtering to one query 349 | but not another (unlike adding view level filtering, which will filter all the 350 | querysets), but that filtering can't be provided at the beginning (for example, it 351 | needs to access a query_param). This is testing the filter_fn. 352 | """ 353 | 354 | view = FilterFnView.as_view() 355 | 356 | request = factory.get('/', {'letter': 'o'}) 357 | 358 | with self.assertNumQueries(2): 359 | response = view(request).render() 360 | 361 | # Check that the plays have been filter to remove those with the letter 'o' 362 | # But the poems haven't been affected 363 | self.assertEqual(response.status_code, status.HTTP_200_OK) 364 | self.assertEqual(response.data, [ 365 | {'genre': 'Comedy', 'title': 'A Midsummer Night\'s Dream', 'year': 1600, 'type': 'Play'}, 366 | {'genre': 'Tragedy', 'title': 'Julius Caesar', 'year': 1623, 'type': 'Play'}, 367 | {'title': "Shall I compare thee to a summer's day?", 'style': 'Sonnet', 'type': 'Poem'}, 368 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet', 'type': 'Poem'}, 369 | ]) 370 | 371 | def test_sorted_flat(self): 372 | """ 373 | Adding the sorting_field attribute should order the flat items according to whatever field 374 | """ 375 | view = SortedFlatView.as_view() 376 | 377 | request = factory.get('/') 378 | with self.assertNumQueries(2): 379 | response = view(request).render() 380 | 381 | self.assertEqual(len(response.data), 6) 382 | self.assertEqual(response.data, self.sorted_results) 383 | 384 | def test_reverse_sorted(self): 385 | """ 386 | Adding a '-' to the front of the sorting_field attribute should order the 387 | flat items in reverse 388 | """ 389 | view = ReversedFlatView.as_view() 390 | 391 | request = factory.get('/') 392 | with self.assertNumQueries(2): 393 | response = view(request).render() 394 | 395 | self.assertEqual(len(response.data), 6) 396 | self.assertEqual(response.data, list(reversed(self.sorted_results))) 397 | 398 | def test_sorting_by_request_parameter(self): 399 | """ 400 | Adding the sorting_field attribute should order the flat items according to whatever field 401 | """ 402 | view = SortingFlatView.as_view() 403 | 404 | for sorting_arg in ('author', '-author'): 405 | request = factory.get('/?o={}'.format(sorting_arg)) 406 | with self.assertNumQueries(2): 407 | response = view(request).render() 408 | 409 | self.assertEqual(len(response.data), 6) 410 | self.assertEqual(response.data, list(reversed(self.sorted_results_w_author)) 411 | if '-' in sorting_arg else self.sorted_results_w_author) 412 | 413 | def test_sorting_by_custom_request_parameter(self): 414 | """ 415 | Adding the sorting_field attribute should order the flat items according to whatever field 416 | """ 417 | view = CustomSortingParamFlatView.as_view() 418 | 419 | for sorting_arg in ('author', '-author'): 420 | request = factory.get('/?custom_o={}'.format(sorting_arg)) 421 | with self.assertNumQueries(2): 422 | response = view(request).render() 423 | 424 | self.assertEqual(len(response.data), 6) 425 | self.assertEqual(response.data, list(reversed(self.sorted_results_w_author)) 426 | if '-' in sorting_arg else self.sorted_results_w_author) 427 | 428 | def test_sorting_list_attribute_failure(self): 429 | """ 430 | Attempts to sort by data value that is a list should fail 431 | """ 432 | view = SortingFlatViewListData.as_view() 433 | request = factory.get('/') 434 | self.assertRaises(ValidationError, view, request, msg='Invalid sorting field: year') 435 | 436 | def test_sorting_by_multiple_parameters(self): 437 | """ 438 | Sorting by multiple fields should work 439 | """ 440 | view = SortingMultipleFieldsFlatView.as_view() 441 | 442 | request = factory.get('/') 443 | with self.assertNumQueries(2): 444 | response = view(request).render() 445 | 446 | self.assertEqual(len(response.data), 6) 447 | self.assertEqual( 448 | response.data, sorted( 449 | sorted(self.sorted_results_w_author, key=lambda x: x['title']), 450 | key=lambda x: x['type'], 451 | ) 452 | ) 453 | 454 | def test_sorting_by_multiple_parameters_reversed(self): 455 | """ 456 | Sorting by multiple fields in descending order should work 457 | """ 458 | view = ReversedSortingMultipleFieldsFlatView.as_view() 459 | 460 | request = factory.get('/') 461 | with self.assertNumQueries(2): 462 | response = view(request).render() 463 | 464 | self.assertEqual(len(response.data), 6) 465 | self.assertEqual( 466 | response.data, sorted( 467 | sorted(self.sorted_results_w_author, key=lambda x: x['title']), 468 | key=lambda x: x['type'], 469 | reverse=True 470 | ) 471 | ) 472 | 473 | def test_sorting_by_multiple_parameters_via_request(self): 474 | """ 475 | Sorting by multiple fields should work 476 | """ 477 | view = SortingFlatView.as_view() 478 | request = factory.get('/?o=type,-title') 479 | with self.assertNumQueries(2): 480 | response = view(request).render() 481 | 482 | self.assertEqual(len(response.data), 6) 483 | self.assertEqual( 484 | response.data, sorted( 485 | sorted(self.sorted_results_w_author, key=lambda x: x['title'], reverse=True), 486 | key=lambda x: x['type'] 487 | ) 488 | ) 489 | 490 | def test_ordered_wrong_sorting(self): 491 | """ 492 | Sorting by a non-shared field should throw a KeyError 493 | """ 494 | view = WrongSortFieldView.as_view() 495 | 496 | request = factory.get('/') 497 | self.assertRaises(ValidationError, view, request) 498 | 499 | def test_dynamic_querylist(self): 500 | """ 501 | using get_querylist allows the construction of dynamic queryLists 502 | """ 503 | view = DynamicQueryView.as_view() 504 | 505 | request = factory.get('/Julius-Caesar') 506 | with self.assertNumQueries(2): 507 | response = view(request, play="Julius-Caesar") 508 | 509 | self.assertEqual(response.status_code, status.HTTP_200_OK) 510 | 511 | self.assertEqual(len(response.data), 3) 512 | self.assertEqual(response.data, [ 513 | {'title': 'Julius Caesar', 'genre': 'Tragedy', 'year': 1623, 'type': 'Play'}, 514 | {'title': "Shall I compare thee to a summer's day?", 'style': 'Sonnet', 'type': 'Poem'}, 515 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet', 'type': 'Poem'} 516 | ]) 517 | 518 | def test_search_filter_view(self): 519 | """ 520 | Tests use of built in DRF filtering with FlatMultipleModelAPIView 521 | """ 522 | view = SearchFilterView.as_view() 523 | 524 | request = factory.get('/', {'search': 'as'}) 525 | 526 | with self.assertNumQueries(2): 527 | response = view(request).render() 528 | 529 | # Check first page of results 530 | self.assertEqual(response.status_code, status.HTTP_200_OK) 531 | self.assertEqual(response.data, [ 532 | {'title': 'As You Like It', 'genre': 'Comedy', 'year': 1623, 'type': 'Play'}, 533 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet', 'type': 'Poem'}, 534 | ]) 535 | 536 | def test_url_endpoint(self): 537 | """ 538 | DRF 3.3 broke the MultipleModelAPIView with a load_queryset call 539 | This test is to replicate (and then fix) that problem 540 | """ 541 | client = APIClient() 542 | response = client.get('/', format='api') 543 | self.assertEqual(response.status_code, status.HTTP_200_OK) 544 | 545 | def test_cached_querylist(self): 546 | view = CachedQueryView.as_view() 547 | 548 | request = factory.get('/Julius-Caesar') 549 | with self.assertNumQueries(2): 550 | response = view(request, play="Julius-Caesar") 551 | with self.assertNumQueries(0): 552 | response = view(request, play="Julius-Caesar") 553 | 554 | self.assertEqual(response.status_code, status.HTTP_200_OK) 555 | 556 | self.assertEqual(len(response.data), 6) 557 | self.assertEqual(response.data, [ 558 | {'genre': 'Tragedy', 'title': 'Romeo And Juliet', 'year': 1597, 'type': 'Play'}, 559 | {'genre': 'Comedy', 'title': 'A Midsummer Night\'s Dream', 'year': 1600, 'type': 'Play'}, 560 | {'genre': 'Tragedy', 'title': 'Julius Caesar', 'year': 1623, 'type': 'Play'}, 561 | {'genre': 'Comedy', 'title': 'As You Like It', 'year': 1623, 'type': 'Play'}, 562 | {'title': "Shall I compare thee to a summer's day?", 'style': 'Sonnet', 'type': 'Poem'}, 563 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet', 'type': 'Poem'}, 564 | ]) 565 | -------------------------------------------------------------------------------- /tests/test_html_renderer.py: -------------------------------------------------------------------------------- 1 | import django.template.loader 2 | from django.template import TemplateDoesNotExist, engines 3 | from django.test import override_settings 4 | from django.conf.urls import url 5 | from rest_framework.test import APIClient 6 | from rest_framework import renderers, status 7 | 8 | from .utils import MultipleModelTestCase 9 | from .models import Play, Poem 10 | from .serializers import PlaySerializer, PoemSerializer 11 | from drf_multiple_model.views import FlatMultipleModelAPIView 12 | 13 | 14 | # Testing TemplateHTMLRenderer view bug 15 | class HTMLRendererView(FlatMultipleModelAPIView): 16 | querylist = ( 17 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 18 | {'queryset': Poem.objects.filter(style="Sonnet"), 'serializer_class': PoemSerializer}, 19 | ) 20 | renderer_classes = (renderers.TemplateHTMLRenderer, renderers.JSONRenderer) 21 | template_name = 'test.html' 22 | 23 | 24 | # Fake URL Patterns for running tests 25 | urlpatterns = [ 26 | url(r"^template$", HTMLRendererView.as_view()), 27 | ] 28 | 29 | 30 | @override_settings(ROOT_URLCONF=__name__) 31 | class TestMMVHTMLRenderer(MultipleModelTestCase): 32 | def setUp(self): 33 | super(TestMMVHTMLRenderer, self).setUp() 34 | 35 | """ 36 | Monkeypatch get_template 37 | Taken from DRF Tests 38 | """ 39 | self.get_template = django.template.loader.get_template 40 | 41 | def get_template(template_name, dirs=None): 42 | if template_name == 'test.html': 43 | return engines['django'].from_string("test: {{ data }}") 44 | raise TemplateDoesNotExist(template_name) 45 | 46 | def select_template(template_name_list, dirs=None, using=None): 47 | if template_name_list == ['test.html']: 48 | return engines['django'].from_string("test: {{ data }}") 49 | raise TemplateDoesNotExist(template_name_list[0]) 50 | 51 | django.template.loader.get_template = get_template 52 | django.template.loader.select_template = select_template 53 | 54 | def test_html_renderer(self): 55 | """ 56 | Testing bug in which results dict failed to be passed into template context 57 | """ 58 | client = APIClient() 59 | response = client.get('/template', format='html') 60 | 61 | # test the data is formatted properly and shows up in the template 62 | self.assertEqual(response.status_code, status.HTTP_200_OK) 63 | self.assertIn('data', response.data) 64 | self.assertContains(response, "Tragedy") 65 | self.assertContains(response, "") 66 | self.assertContains(response, "decrepit") 67 | 68 | # test that the JSONRenderer does NOT add the dictionary wrapper to the data 69 | response = client.get('/template?format=json') 70 | 71 | # test the data is formatted properly and shows up in the template 72 | self.assertEqual(response.status_code, status.HTTP_200_OK) 73 | self.assertNotIn('data', response.data) 74 | self.assertNotIn('', response) 75 | 76 | def tearDown(self): 77 | """ 78 | Revert monkeypatching 79 | """ 80 | django.template.loader.get_template = self.get_template 81 | -------------------------------------------------------------------------------- /tests/test_object_view.py: -------------------------------------------------------------------------------- 1 | from django.test import override_settings 2 | from django.core.exceptions import ValidationError 3 | from django.conf.urls import url 4 | from django.core.cache import cache 5 | from rest_framework.test import APIClient, APIRequestFactory 6 | from rest_framework import status, filters 7 | 8 | from .utils import MultipleModelTestCase 9 | from .models import Play, Poem 10 | from .serializers import PlaySerializer, PoemSerializer 11 | from drf_multiple_model.views import ObjectMultipleModelAPIView 12 | 13 | 14 | factory = APIRequestFactory() 15 | 16 | 17 | class BasicObjectView(ObjectMultipleModelAPIView): 18 | querylist = ( 19 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 20 | {'queryset': Poem.objects.filter(style="Sonnet"), 'serializer_class': PoemSerializer}, 21 | ) 22 | 23 | 24 | class CustomLabelView(ObjectMultipleModelAPIView): 25 | querylist = ( 26 | { 27 | 'queryset': Play.objects.all(), 28 | 'serializer_class': PlaySerializer, 29 | 'label': 'Drama', 30 | }, 31 | { 32 | 'queryset': Poem.objects.filter(style="Sonnet"), 33 | 'serializer_class': PoemSerializer, 34 | 'label': 'Poetry', 35 | }, 36 | ) 37 | 38 | 39 | class DynamicQueryView(ObjectMultipleModelAPIView): 40 | def get_querylist(self): 41 | title = self.kwargs['play'].replace('-', ' ') 42 | 43 | querylist = ( 44 | {'queryset': Play.objects.filter(title=title), 'serializer_class': PlaySerializer}, 45 | {'queryset': Poem.objects.filter(style="Sonnet"), 'serializer_class': PoemSerializer}, 46 | ) 47 | 48 | return querylist 49 | 50 | 51 | class SearchFilterView(BasicObjectView): 52 | filter_backends = (filters.SearchFilter,) 53 | search_fields = ('title',) 54 | 55 | 56 | # Testing filter_fn 57 | def title_without_letter(queryset, request, *args, **kwargs): 58 | letter_to_exclude = request.query_params['letter'] 59 | return queryset.exclude(title__icontains=letter_to_exclude) 60 | 61 | 62 | class FilterFnView(ObjectMultipleModelAPIView): 63 | querylist = ( 64 | { 65 | 'queryset': Play.objects.all(), 66 | 'serializer_class': PlaySerializer, 67 | 'filter_fn': title_without_letter, 68 | }, 69 | { 70 | 'queryset': Poem.objects.filter(style="Sonnet"), 71 | 'serializer_class': PoemSerializer, 72 | }, 73 | ) 74 | 75 | 76 | class CachedQueryView(ObjectMultipleModelAPIView): 77 | querylist = ( 78 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 79 | {'queryset': Poem.objects.filter(style="Sonnet"), 'serializer_class': PoemSerializer}, 80 | ) 81 | 82 | def load_queryset(self, query_data, request, *args, **kwargs): 83 | queryset = cache.get('{}-queryset'.format(query_data['queryset'].model.__name__)) 84 | if not queryset: 85 | queryset = query_data['queryset'].all() 86 | cache.set('{}-queryset'.format(query_data['queryset'].model.__name__), queryset) 87 | return queryset 88 | 89 | 90 | # Broken Views 91 | class NoQuerylistView(ObjectMultipleModelAPIView): 92 | pass 93 | 94 | 95 | class NoQuerysetView(ObjectMultipleModelAPIView): 96 | querylist = [ 97 | {'serializer_class': PlaySerializer}, 98 | {'serializer_class': PoemSerializer}, 99 | ] 100 | 101 | 102 | class NoSerializerClassView(ObjectMultipleModelAPIView): 103 | querylist = [ 104 | {'queryset': Play.objects.all()}, 105 | {'queryset': Poem.objects.all()}, 106 | ] 107 | 108 | 109 | urlpatterns = [ 110 | url(r'^$', BasicObjectView.as_view()), 111 | ] 112 | 113 | 114 | # TESTS 115 | @override_settings(ROOT_URLCONF=__name__) 116 | class TestMMObjectViews(MultipleModelTestCase): 117 | maxDiff = None 118 | 119 | def test_post(self): 120 | """ 121 | POST requests should throw a 405 Error 122 | """ 123 | view = BasicObjectView.as_view() 124 | 125 | data = {'fake': 'data'} 126 | request = factory.post('/', data, format='json') 127 | 128 | with self.assertNumQueries(0): 129 | response = view(request).render() 130 | 131 | self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) 132 | self.assertEqual(response.data, {"detail": 'Method "POST" not allowed.'}) 133 | 134 | def test_put(self): 135 | """ 136 | PUT requests should throw a 405 Error 137 | """ 138 | view = BasicObjectView.as_view() 139 | 140 | data = {'fake': 'data'} 141 | request = factory.put('/', data, format='json') 142 | 143 | with self.assertNumQueries(0): 144 | response = view(request).render() 145 | 146 | self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) 147 | self.assertEqual(response.data, {"detail": 'Method "PUT" not allowed.'}) 148 | 149 | def test_delete(self): 150 | """ 151 | DELETE requests should throw a 405 Error 152 | """ 153 | view = BasicObjectView.as_view() 154 | 155 | request = factory.delete('/') 156 | 157 | with self.assertNumQueries(0): 158 | response = view(request).render() 159 | 160 | self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) 161 | self.assertEqual(response.data, {"detail": 'Method "DELETE" not allowed.'}) 162 | 163 | def test_no_querylist(self): 164 | """ 165 | A view with no querylist and no `get_querylist` overwrite should raise 166 | an assertion error with the appropriate message 167 | """ 168 | view = NoQuerylistView.as_view() 169 | 170 | request = factory.get('/') 171 | 172 | with self.assertRaises(AssertionError) as error: 173 | view(request).render() 174 | 175 | self.assertEqual(str(error.exception), ( 176 | 'NoQuerylistView should either include a `querylist` attribute, ' 177 | 'or override the `get_querylist()` method.' 178 | )) 179 | 180 | def test_no_queryset(self): 181 | """ 182 | A querylist with no `queryset` key should raise a ValidationError with the 183 | appropriate message 184 | """ 185 | view = NoQuerysetView.as_view() 186 | 187 | request = factory.get('/') 188 | 189 | with self.assertRaises(ValidationError) as error: 190 | view(request).render() 191 | 192 | self.assertEqual(error.exception.message, ( 193 | 'All items in the NoQuerysetView querylist attribute ' 194 | 'should contain a `queryset` key' 195 | )) 196 | 197 | def test_no_serializer_class(self): 198 | """ 199 | A querylist with no `serializer_class` key should raise a ValidationError with the 200 | appropriate message 201 | """ 202 | view = NoSerializerClassView.as_view() 203 | 204 | request = factory.get('/') 205 | 206 | with self.assertRaises(ValidationError) as error: 207 | view(request).render() 208 | 209 | self.assertEqual(error.exception.message, ( 210 | 'All items in the NoSerializerClassView querylist attribute ' 211 | 'should contain a `serializer_class` key' 212 | )) 213 | 214 | def test_basic_object_view(self): 215 | """ 216 | The default setting for the `ObjectMultipleModelView` should return 217 | the serialized objects in querylist order 218 | """ 219 | view = BasicObjectView.as_view() 220 | 221 | request = factory.get('/') 222 | with self.assertNumQueries(2): 223 | response = view(request).render() 224 | 225 | self.assertEqual(response.status_code, status.HTTP_200_OK) 226 | 227 | self.assertEqual(len(response.data), 2) 228 | self.assertEqual(response.data, { 229 | 'Play': [ 230 | {'title': 'Romeo And Juliet', 'genre': 'Tragedy', 'year': 1597}, 231 | {'title': "A Midsummer Night's Dream", 'genre': 'Comedy', 'year': 1600}, 232 | {'title': 'Julius Caesar', 'genre': 'Tragedy', 'year': 1623}, 233 | {'title': 'As You Like It', 'genre': 'Comedy', 'year': 1623}, 234 | ], 235 | 'Poem': [ 236 | {'title': "Shall I compare thee to a summer's day?", 'style': 'Sonnet'}, 237 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet'} 238 | ] 239 | }) 240 | 241 | def test_new_labels(self): 242 | """ 243 | Adding the 'label' key to querylist elements should use those labels 244 | instead of the model names 245 | """ 246 | view = CustomLabelView.as_view() 247 | 248 | request = factory.get('/') 249 | with self.assertNumQueries(2): 250 | response = view(request).render() 251 | 252 | self.assertEqual(response.status_code, status.HTTP_200_OK) 253 | 254 | self.assertEqual(len(response.data), 2) 255 | self.assertEqual(response.data, { 256 | 'Drama': [ 257 | {'title': 'Romeo And Juliet', 'genre': 'Tragedy', 'year': 1597}, 258 | {'title': "A Midsummer Night's Dream", 'genre': 'Comedy', 'year': 1600}, 259 | {'title': 'Julius Caesar', 'genre': 'Tragedy', 'year': 1623}, 260 | {'title': 'As You Like It', 'genre': 'Comedy', 'year': 1623}, 261 | ], 262 | 'Poetry': [ 263 | {'title': "Shall I compare thee to a summer's day?", 'style': 'Sonnet'}, 264 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet'} 265 | ] 266 | }) 267 | 268 | def test_filter_fn_view(self): 269 | """ 270 | The filter function is useful if you want to apply filtering to one query 271 | but not another (unlike adding view level filtering, which will filter all the 272 | querysets), but that filtering can't be provided at the beginning (for example, it 273 | needs to access a query_param). This is testing the filter_fn. 274 | """ 275 | view = FilterFnView.as_view() 276 | 277 | request = factory.get('/', {'letter': 'o'}) 278 | 279 | with self.assertNumQueries(2): 280 | response = view(request).render() 281 | 282 | # Check that the plays have been filter to remove those with the letter 'o' 283 | # But the poems haven't been affected 284 | self.assertEqual(response.status_code, status.HTTP_200_OK) 285 | self.assertEqual(response.data, { 286 | 'Play': [ 287 | {'title': "A Midsummer Night's Dream", 'genre': 'Comedy', 'year': 1600}, 288 | {'title': 'Julius Caesar', 'genre': 'Tragedy', 'year': 1623}, 289 | ], 290 | 'Poem': [ 291 | {'title': "Shall I compare thee to a summer's day?", 'style': 'Sonnet'}, 292 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet'} 293 | ] 294 | }) 295 | 296 | def test_dynamic_querylist(self): 297 | """ 298 | using get_querylist allows the construction of dynamic queryLists 299 | """ 300 | view = DynamicQueryView.as_view() 301 | 302 | request = factory.get('/Julius-Caesar') 303 | with self.assertNumQueries(2): 304 | response = view(request, play="Julius-Caesar") 305 | 306 | self.assertEqual(response.status_code, status.HTTP_200_OK) 307 | 308 | self.assertEqual(len(response.data), 2) 309 | self.assertEqual(response.data, { 310 | 'Play': [ 311 | {'title': 'Julius Caesar', 'genre': 'Tragedy', 'year': 1623}, 312 | ], 313 | 'Poem': [ 314 | {'title': "Shall I compare thee to a summer's day?", 'style': 'Sonnet'}, 315 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet'} 316 | ] 317 | }) 318 | 319 | def test_cached_querylist(self): 320 | view = CachedQueryView.as_view() 321 | 322 | request = factory.get('/Julius-Caesar') 323 | with self.assertNumQueries(2): 324 | response = view(request) 325 | with self.assertNumQueries(0): 326 | response = view(request) 327 | 328 | self.assertEqual(response.status_code, status.HTTP_200_OK) 329 | self.assertEqual(response.data, { 330 | 'Play': [ 331 | {'title': 'Romeo And Juliet', 'genre': 'Tragedy', 'year': 1597}, 332 | {'title': "A Midsummer Night's Dream", 'genre': 'Comedy', 'year': 1600}, 333 | {'title': 'Julius Caesar', 'genre': 'Tragedy', 'year': 1623}, 334 | {'title': 'As You Like It', 'genre': 'Comedy', 'year': 1623}, 335 | ], 336 | 'Poem': [ 337 | {'title': "Shall I compare thee to a summer's day?", 'style': 'Sonnet'}, 338 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet'} 339 | ] 340 | }) 341 | 342 | def test_search_filter_view(self): 343 | """ 344 | Tests use of built in DRF filtering with ObjectMultipleModelAPIView 345 | """ 346 | view = SearchFilterView.as_view() 347 | 348 | request = factory.get('/', {'search': 'as'}) 349 | 350 | with self.assertNumQueries(2): 351 | response = view(request).render() 352 | 353 | self.assertEqual(response.status_code, status.HTTP_200_OK) 354 | self.assertEqual(response.data, { 355 | 'Play': [ 356 | {'title': 'As You Like It', 'genre': 'Comedy', 'year': 1623}, 357 | ], 358 | 'Poem': [ 359 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet'} 360 | ] 361 | }) 362 | 363 | def test_url_endpoint(self): 364 | """ 365 | DRF 3.3 broke the MultipleModelAPIView with a load_queryset call 366 | This test is to replicate (and then fix) that problem 367 | """ 368 | client = APIClient() 369 | response = client.get('/', format='api') 370 | self.assertEqual(response.status_code, status.HTTP_200_OK) 371 | -------------------------------------------------------------------------------- /tests/test_pagination.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APIRequestFactory 2 | from rest_framework import status 3 | 4 | from .utils import MultipleModelTestCase 5 | from .models import Play, Poem 6 | from .serializers import PlaySerializer, PoemSerializer 7 | from drf_multiple_model.views import ObjectMultipleModelAPIView, FlatMultipleModelAPIView 8 | from drf_multiple_model.pagination import MultipleModelLimitOffsetPagination 9 | 10 | 11 | factory = APIRequestFactory() 12 | 13 | 14 | class LimitPagination(MultipleModelLimitOffsetPagination): 15 | default_limit = 2 16 | 17 | 18 | class ObjectLimitPaginationView(ObjectMultipleModelAPIView): 19 | pagination_class = LimitPagination 20 | querylist = ( 21 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 22 | {'queryset': Poem.objects.all(), 'serializer_class': PoemSerializer}, 23 | ) 24 | 25 | 26 | class FlatLimitPaginationView(FlatMultipleModelAPIView): 27 | pagination_class = LimitPagination 28 | querylist = ( 29 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 30 | {'queryset': Poem.objects.all(), 'serializer_class': PoemSerializer}, 31 | ) 32 | 33 | 34 | class LimitPaginationTests(MultipleModelTestCase): 35 | def test_basic_object_pagination(self): 36 | view = ObjectLimitPaginationView.as_view() 37 | 38 | request = factory.get('/') 39 | 40 | # Additional queries for getting counts 41 | with self.assertNumQueries(4): 42 | response = view(request).render() 43 | 44 | self.assertEqual(response.status_code, status.HTTP_200_OK) 45 | 46 | # Each response should contain the same number of items as the limit 47 | self.assertEqual(len(response.data['results']['Play']), 2) 48 | self.assertEqual(len(response.data['results']['Poem']), 2) 49 | 50 | # The count should be equal to the greatest count value/table size 51 | self.assertEqual(response.data['highest_count'], 4) 52 | self.assertEqual(response.data['overall_total'], 7) 53 | 54 | # check that links are properly formed 55 | self.assertEqual(response.data['next'], 'http://testserver/?limit=2&offset=2') 56 | self.assertEqual(response.data['previous'], None) 57 | 58 | # check second page of results 59 | request = factory.get('/', {'offset': 2}) 60 | response = view(request).render() 61 | 62 | self.assertEqual(response.status_code, status.HTTP_200_OK) 63 | 64 | # There are only three Poems, so this response should have only 1 Poem, but 2 Plays 65 | self.assertEqual(len(response.data['results']['Play']), 2) 66 | self.assertEqual(len(response.data['results']['Poem']), 1) 67 | 68 | # The count values should not change 69 | self.assertEqual(response.data['highest_count'], 4) 70 | self.assertEqual(response.data['overall_total'], 7) 71 | 72 | # Check the new links 73 | self.assertEqual(response.data['previous'], 'http://testserver/?limit=2') 74 | self.assertEqual(response.data['next'], None) 75 | 76 | def test_basic_flat_pagination(self): 77 | view = FlatLimitPaginationView.as_view() 78 | 79 | request = factory.get('/') 80 | 81 | # Additional queries for getting counts 82 | with self.assertNumQueries(4): 83 | response = view(request).render() 84 | 85 | self.assertEqual(response.status_code, status.HTTP_200_OK) 86 | 87 | # Results length should be number_querylist_items * limit 88 | self.assertEqual(len(response.data['results']), 4) 89 | 90 | # The count should be equal to the greatest count value/table size 91 | self.assertEqual(response.data['highest_count'], 4) 92 | self.assertEqual(response.data['overall_total'], 7) 93 | 94 | # check that links are properly formed 95 | self.assertEqual(response.data['next'], 'http://testserver/?limit=2&offset=2') 96 | self.assertEqual(response.data['previous'], None) 97 | 98 | # check second page of results 99 | request = factory.get('/', {'offset': 2}) 100 | response = view(request).render() 101 | 102 | self.assertEqual(response.status_code, status.HTTP_200_OK) 103 | 104 | # There are only three Poems, so this response should have only 1 Poem, but 2 Plays 105 | self.assertEqual(len(response.data['results']), 3) 106 | 107 | # The count values should not change 108 | self.assertEqual(response.data['highest_count'], 4) 109 | self.assertEqual(response.data['overall_total'], 7) 110 | 111 | # Check the new links 112 | self.assertEqual(response.data['previous'], 'http://testserver/?limit=2') 113 | self.assertEqual(response.data['next'], None) 114 | -------------------------------------------------------------------------------- /tests/test_viewsets.py: -------------------------------------------------------------------------------- 1 | from django.test import override_settings 2 | from rest_framework.test import APIClient 3 | from rest_framework import routers, status 4 | 5 | from .utils import MultipleModelTestCase 6 | from .models import Play, Poem 7 | from .serializers import PlaySerializer, PoemSerializer 8 | from drf_multiple_model.viewsets import FlatMultipleModelAPIViewSet, ObjectMultipleModelAPIViewSet 9 | 10 | 11 | class FlatViewSet(FlatMultipleModelAPIViewSet): 12 | querylist = ( 13 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 14 | {'queryset': Poem.objects.filter(style="Sonnet"), 'serializer_class': PoemSerializer}, 15 | ) 16 | 17 | 18 | class ObjectViewSet(ObjectMultipleModelAPIViewSet): 19 | querylist = ( 20 | {'queryset': Play.objects.all(), 'serializer_class': PlaySerializer}, 21 | {'queryset': Poem.objects.filter(style="Sonnet"), 'serializer_class': PoemSerializer}, 22 | ) 23 | 24 | 25 | # Routers for testing viewset 26 | router = routers.SimpleRouter() 27 | router.register(r'flat', FlatViewSet, base_name='flat') 28 | router.register(r'object', ObjectViewSet, base_name='object') 29 | 30 | urlpatterns = router.urls 31 | 32 | 33 | # TESTS 34 | @override_settings(ROOT_URLCONF=__name__) 35 | class TestMMObjectViews(MultipleModelTestCase): 36 | maxDiff = None 37 | 38 | def test_object_viewset(self): 39 | """ 40 | Tests the ObjectMutlipleModelAPIViewSet with the default settings 41 | """ 42 | client = APIClient() 43 | response = client.get('/object/', format='api') 44 | self.assertEqual(response.status_code, status.HTTP_200_OK) 45 | 46 | self.assertEqual(len(response.data), 2) 47 | self.assertEqual(response.data, { 48 | 'Play': [ 49 | {'title': 'Romeo And Juliet', 'genre': 'Tragedy', 'year': 1597}, 50 | {'title': "A Midsummer Night's Dream", 'genre': 'Comedy', 'year': 1600}, 51 | {'title': 'Julius Caesar', 'genre': 'Tragedy', 'year': 1623}, 52 | {'title': 'As You Like It', 'genre': 'Comedy', 'year': 1623}, 53 | ], 54 | 'Poem': [ 55 | {'title': "Shall I compare thee to a summer's day?", 'style': 'Sonnet'}, 56 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet'} 57 | ] 58 | }) 59 | 60 | def test_flat_viewset(self): 61 | """ 62 | Tests the ObjectMutlipleModelAPIViewSet with the default settings 63 | """ 64 | client = APIClient() 65 | response = client.get('/flat/', format='api') 66 | self.assertEqual(response.status_code, status.HTTP_200_OK) 67 | 68 | self.assertEqual(len(response.data), 6) 69 | self.assertEqual(response.data, [ 70 | {'genre': 'Tragedy', 'title': 'Romeo And Juliet', 'year': 1597, 'type': 'Play'}, 71 | {'genre': 'Comedy', 'title': 'A Midsummer Night\'s Dream', 'year': 1600, 'type': 'Play'}, 72 | {'genre': 'Tragedy', 'title': 'Julius Caesar', 'year': 1623, 'type': 'Play'}, 73 | {'genre': 'Comedy', 'title': 'As You Like It', 'year': 1623, 'type': 'Play'}, 74 | {'title': "Shall I compare thee to a summer's day?", 'style': 'Sonnet', 'type': 'Poem'}, 75 | {'title': "As a decrepit father takes delight", 'style': 'Sonnet', 'type': 'Poem'}, 76 | ]) 77 | -------------------------------------------------------------------------------- /tests/utils.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from django.core.cache import cache 3 | 4 | from .models import Play, Poem, Author 5 | 6 | 7 | class MultipleModelTestCase(TestCase): 8 | """ 9 | Extends TestCase to add setup needed by all MM tests 10 | """ 11 | def setUp(self): 12 | cache.clear() 13 | 14 | Play.objects.bulk_create([ 15 | Play(title='Romeo And Juliet', 16 | genre='Tragedy', 17 | year=1597, 18 | author=Author.objects.create(name='Play Shakespeare 1')), 19 | Play(title="A Midsummer Night's Dream", 20 | genre='Comedy', 21 | year=1600, 22 | author=Author.objects.create(name='Play Shakespeare 2')), 23 | Play(title='Julius Caesar', 24 | genre='Tragedy', 25 | year=1623, 26 | author=Author.objects.create(name='Play Shakespeare 3')), 27 | Play(title='As You Like It', 28 | genre='Comedy', 29 | year=1623, 30 | author=Author.objects.create(name='Play Shakespeare 4')) 31 | ]) 32 | 33 | Poem.objects.bulk_create([ 34 | Poem(title="Shall I compare thee to a summer's day?", 35 | style="Sonnet", 36 | author=Author.objects.create(name='Poem Shakespeare 1')), 37 | Poem(title="As a decrepit father takes delight", 38 | style="Sonnet", 39 | author=Author.objects.create(name='Poem Shakespeare 2')), 40 | Poem(title="A Lover's Complaint", 41 | style="Narrative", 42 | author=Author.objects.create(name='Poem Shakespeare 3')) 43 | ]) 44 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts=--tb=short 3 | 4 | [tox] 5 | skipsdist = True 6 | envlist = 7 | {py35,py36,py37}-django22, 8 | {py36,py37,py38}-django30, 9 | {py36,py37,py38,py39}-django31, 10 | {py36,py37,py38,py39}-django32, 11 | 12 | [testenv] 13 | commands = python manage.py test 14 | deps = 15 | django22: Django>=2.2,<3.0 16 | django30: Django>=3.0,<3.1 17 | django31: Django>=3.1,<3.2 18 | django32: Django>=3.2a1,<4.0 19 | -rrequirements.txt 20 | basepython = 21 | py39: python3.9 22 | py38: python3.8 23 | py37: python3.7 24 | py36: python3.6 25 | py35: python3.5 26 | --------------------------------------------------------------------------------