pax_global_header00006660000000000000000000000064136135315670014524gustar00rootroot0000000000000052 comment=503c22f8b442b2bff1f9060c58c024d7d4caabf2 drf-extensions-0.6.0/000077500000000000000000000000001361353156700144775ustar00rootroot00000000000000drf-extensions-0.6.0/.gitignore000066400000000000000000000001101361353156700164570ustar00rootroot00000000000000__pycache__/ *.pyc *.egg-info .tox *.egg .idea env build dist .DS_Store drf-extensions-0.6.0/.travis.yml000066400000000000000000000002231361353156700166050ustar00rootroot00000000000000language: python cache: pip dist: bionic sudo: false python: - 3.6 - 3.7 - 3.8 install: - pip install tox tox-travis script: - tox -r drf-extensions-0.6.0/AUTHORS.md000066400000000000000000000003461361353156700161510ustar00rootroot00000000000000## Original Author --------------- Gennady Chibisov https://github.com/chibisov ## Core maintainer Asif Saif Uddin https://github.com/auvipy ## Contributors ------------ Luke Murphy https://github.com/lwm drf-extensions-0.6.0/GNUmakefile000066400000000000000000000003651361353156700165550ustar00rootroot00000000000000build_docs: PYTHONIOENCODING=utf-8 python docs/backdoc.py --title "Django Rest Framework extensions documentation" < docs/index.md > docs/index.html watch_docs: make build_docs watchmedo shell-command -p "*.md" -R -c "make build_docs" docs/ drf-extensions-0.6.0/LICENSE000066400000000000000000000020741361353156700155070ustar00rootroot00000000000000The MIT License (MIT) Copyright (c) 2013 Gennady Chibisov. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. drf-extensions-0.6.0/MANIFEST.in000066400000000000000000000003061361353156700162340ustar00rootroot00000000000000include LICENSE include README.md include tox.ini recursive-include docs *.md *.html *.txt *.py recursive-include tests_app requirements.txt *.py recursive-exclude * __pycache__ global-exclude *pyc drf-extensions-0.6.0/README.md000066400000000000000000000143151361353156700157620ustar00rootroot00000000000000## Django REST Framework extensions DRF-extensions is a collection of custom extensions for [Django REST Framework](https://github.com/tomchristie/django-rest-framework) Full documentation for project is available at [http://chibisov.github.io/drf-extensions/docs](http://chibisov.github.io/drf-extensions/docs) [![Build Status](https://travis-ci.org/chibisov/drf-extensions.svg?branch=master)](https://travis-ci.org/chibisov/drf-extensions) [![Backers on Open Collective](https://opencollective.com/drf-extensions/backers/badge.svg)](#backers) [![Sponsors on Open Collective](https://opencollective.com/drf-extensions/sponsors/badge.svg)](#sponsors) [![PyPI](https://img.shields.io/pypi/v/drf-extensions.svg)](https://pypi.python.org/pypi/drf-extensions) ### Sponsor [Tidelift gives software development teams a single source for purchasing and maintaining their software, with professional grade assurances from the experts who know it best, while seamlessly integrating with existing tools.](https://tidelift.com/subscription/pkg/pypi-drf-extensions?utm_source=pypi-drf-extensions&utm_medium=referral&utm_campaign=readme) ## Requirements * Tested for Python 3.6, 3.7 and 3.8 * Tested for Django Rest Framework 3.9, 3.10 and 3.11 * Tested for Django 1.11, 2.1, 2.2 and 3.0 * Tested for django-filter 2.1.0 ## Installation: pip3 install drf-extensions or from github pip3 install https://github.com/chibisov/drf-extensions/archive/master.zip ## Some features * DetailSerializerMixin * Caching * Conditional requests * Customizable key construction for caching and conditional requests * Nested routes * Bulk operations Read more in [documentation](http://chibisov.github.io/drf-extensions/docs) ## Development Running the tests: $ pip3 install tox $ tox -- tests_app Running test for exact environment: $ tox -e py38 -- tests_app Recreate envs before running tests: $ tox --recreate -- tests_app Pass custom arguments: $ tox -- tests_app --verbosity=3 Run with pdb support: $ tox -- tests_app --processes=0 --nocapture Run exact TestCase: $ tox -- tests_app.tests.unit.mixins.tests:DetailSerializerMixinTest_serializer_detail_class Run tests from exact module: $ tox -- tests_app.tests.unit.mixins.tests Build docs: $ make build_docs Automatically build docs by watching changes: $ pip install watchdog $ make watch_docs ## Developing new features Every new feature should be: * Documented * Tested * Implemented * Pushed to main repository ### How to write documentation When new feature implementation starts you should place it into `development version` pull. Add `Development version` section to `Release notes` and describe every new feature in it. Use `#anchors` to facilitate navigation. Every feature should have title and information that it was implemented in current development version. For example if we've just implemented `Usage of the specific cache`: ... #### Usage of the specific cache *New in DRF-extensions development version* `@cache_response` can also take... ... ### Release notes ... #### Development version * Added ability to [use a specific cache](#usage-of-the-specific-cache) for `@cache_response` decorator ## Publishing new releases Increment version in `rest_framework_extensions/__init__.py`. For example: __version__ = '0.2.2' # from 0.2.1 Move to new version section all release notes in documentation. Add date for release note section. Replace in documentation all `New in DRF-extensions development version` notes to `New in DRF-extensions 0.2.2`. Rebuild documentation. Run tests. Commit changes with message "Version 0.2.2" Add new tag version for commit: $ git tag 0.2.2 Push to master with tags: $ git push origin master --tags Don't forget to merge `master` to `gh-pages` branch and push to origin: $ git co gh-pages $ git merge --no-ff master $ git push origin gh-pages Publish to pypi: $ python setup.py publish ## Contributors This project exists thanks to all the people who contribute. ## Backers Thank you to all our backers! 🙏 [[Become a backer](https://opencollective.com/drf-extensions#backer)] ## Sponsors Support this project by becoming a sponsor. Your logo will show up here with a link to your website. [[Become a sponsor](https://opencollective.com/drf-extensions#sponsor)] drf-extensions-0.6.0/docs/000077500000000000000000000000001361353156700154275ustar00rootroot00000000000000drf-extensions-0.6.0/docs/backdoc.py000066400000000000000000006160151361353156700174000ustar00rootroot00000000000000#!/usr/bin/env python """ Backdoc is a tool for backbone-like documentation generation. Backdoc main goal is to help to generate one page documentation from one markdown source file. https://github.com/chibisov/backdoc """ import sys import argparse # Copyright (c) 2012 Trent Mick. # Copyright (c) 2007-2008 ActiveState Corp. # License: MIT (http://www.opensource.org/licenses/mit-license.php) r"""A fast and complete Python implementation of Markdown. [from http://daringfireball.net/projects/markdown/] > Markdown is a text-to-HTML filter; it translates an easy-to-read / > easy-to-write structured text format into HTML. Markdown's text > format is most similar to that of plain text email, and supports > features such as headers, *emphasis*, code blocks, blockquotes, and > links. > > Markdown's syntax is designed not as a generic markup language, but > specifically to serve as a front-end to (X)HTML. You can use span-level > HTML tags anywhere in a Markdown document, and you can use block level > HTML tags (like
and as well). Module usage: >>> import markdown2 >>> markdown2.markdown("*boo!*") # or use `html = markdown_path(PATH)` u'

boo!

\n' >>> markdowner = Markdown() >>> markdowner.convert("*boo!*") u'

boo!

\n' >>> markdowner.convert("**boom!**") u'

boom!

\n' This implementation of Markdown implements the full "core" syntax plus a number of extras (e.g., code syntax coloring, footnotes) as described on . """ cmdln_desc = """A fast and complete Python implementation of Markdown, a text-to-HTML conversion tool for web writers. Supported extra syntax options (see -x|--extras option below and see for details): * code-friendly: Disable _ and __ for em and strong. * cuddled-lists: Allow lists to be cuddled to the preceding paragraph. * fenced-code-blocks: Allows a code block to not have to be indented by fencing it with '```' on a line before and after. Based on with support for syntax highlighting. * footnotes: Support footnotes as in use on daringfireball.net and implemented in other Markdown processors (tho not in Markdown.pl v1.0.1). * header-ids: Adds "id" attributes to headers. The id value is a slug of the header text. * html-classes: Takes a dict mapping html tag names (lowercase) to a string to use for a "class" tag attribute. Currently only supports "pre" and "code" tags. Add an issue if you require this for other tags. * markdown-in-html: Allow the use of `markdown="1"` in a block HTML tag to have markdown processing be done on its contents. Similar to but with some limitations. * metadata: Extract metadata from a leading '---'-fenced block. See for details. * nofollow: Add `rel="nofollow"` to add `` tags with an href. See . * pyshell: Treats unindented Python interactive shell sessions as blocks. * link-patterns: Auto-link given regex patterns in text (e.g. bug number references, revision number references). * smarty-pants: Replaces ' and " with curly quotation marks or curly apostrophes. Replaces --, ---, ..., and . . . with en dashes, em dashes, and ellipses. * toc: The returned HTML string gets a new "toc_html" attribute which is a Table of Contents for the document. (experimental) * xml: Passes one-liner processing instructions and namespaced XML tags. * wiki-tables: Google Code Wiki-style tables. See . """ # Dev Notes: # - Python's regex syntax doesn't have '\z', so I'm using '\Z'. I'm # not yet sure if there implications with this. Compare 'pydoc sre' # and 'perldoc perlre'. __version_info__ = (2, 1, 1) __version__ = '.'.join(map(str, __version_info__)) __author__ = "Trent Mick" import os import sys from pprint import pprint import re import logging try: from hashlib import md5 except ImportError: from md5 import md5 import optparse from random import random, randint import codecs #---- Python version compat try: from urllib.parse import quote # python3 except ImportError: from urllib import quote # python2 if sys.version_info[:2] < (2,4): from sets import Set as set def reversed(sequence): for i in sequence[::-1]: yield i # Use `bytes` for byte strings and `unicode` for unicode strings (str in Py3). if sys.version_info[0] <= 2: py3 = False try: bytes except NameError: bytes = str base_string_type = basestring elif sys.version_info[0] >= 3: py3 = True unicode = str base_string_type = str #---- globals DEBUG = False log = logging.getLogger("markdown") DEFAULT_TAB_WIDTH = 4 SECRET_SALT = bytes(randint(0, 1000000)) def _hash_text(s): return 'md5-' + md5(SECRET_SALT + s.encode("utf-8")).hexdigest() # Table of hash values for escaped characters: g_escape_table = dict([(ch, _hash_text(ch)) for ch in '\\`*_{}[]()>#+-.!']) #---- exceptions class MarkdownError(Exception): pass #---- public api def markdown_path(path, encoding="utf-8", html4tags=False, tab_width=DEFAULT_TAB_WIDTH, safe_mode=None, extras=None, link_patterns=None, use_file_vars=False): fp = codecs.open(path, 'r', encoding) text = fp.read() fp.close() return Markdown(html4tags=html4tags, tab_width=tab_width, safe_mode=safe_mode, extras=extras, link_patterns=link_patterns, use_file_vars=use_file_vars).convert(text) def markdown(text, html4tags=False, tab_width=DEFAULT_TAB_WIDTH, safe_mode=None, extras=None, link_patterns=None, use_file_vars=False): return Markdown(html4tags=html4tags, tab_width=tab_width, safe_mode=safe_mode, extras=extras, link_patterns=link_patterns, use_file_vars=use_file_vars).convert(text) class Markdown: # The dict of "extras" to enable in processing -- a mapping of # extra name to argument for the extra. Most extras do not have an # argument, in which case the value is None. # # This can be set via (a) subclassing and (b) the constructor # "extras" argument. extras = None urls = None titles = None html_blocks = None html_spans = None html_removed_text = "[HTML_REMOVED]" # for compat with markdown.py # Used to track when we're inside an ordered or unordered list # (see _ProcessListItems() for details): list_level = 0 _ws_only_line_re = re.compile(r"^[ \t]+$", re.M) def __init__(self, html4tags=False, tab_width=4, safe_mode=None, extras=None, link_patterns=None, use_file_vars=False): if html4tags: self.empty_element_suffix = ">" else: self.empty_element_suffix = " />" self.tab_width = tab_width # For compatibility with earlier markdown2.py and with # markdown.py's safe_mode being a boolean, # safe_mode == True -> "replace" if safe_mode is True: self.safe_mode = "replace" else: self.safe_mode = safe_mode # Massaging and building the "extras" info. if self.extras is None: self.extras = {} elif not isinstance(self.extras, dict): self.extras = dict([(e, None) for e in self.extras]) if extras: if not isinstance(extras, dict): extras = dict([(e, None) for e in extras]) self.extras.update(extras) assert isinstance(self.extras, dict) if "toc" in self.extras and not "header-ids" in self.extras: self.extras["header-ids"] = None # "toc" implies "header-ids" self._instance_extras = self.extras.copy() self.link_patterns = link_patterns self.use_file_vars = use_file_vars self._outdent_re = re.compile(r'^(\t|[ ]{1,%d})' % tab_width, re.M) self._escape_table = g_escape_table.copy() if "smarty-pants" in self.extras: self._escape_table['"'] = _hash_text('"') self._escape_table["'"] = _hash_text("'") def reset(self): self.urls = {} self.titles = {} self.html_blocks = {} self.html_spans = {} self.list_level = 0 self.extras = self._instance_extras.copy() if "footnotes" in self.extras: self.footnotes = {} self.footnote_ids = [] if "header-ids" in self.extras: self._count_from_header_id = {} # no `defaultdict` in Python 2.4 if "metadata" in self.extras: self.metadata = {} # Per "rel" # should only be used in tags with an "href" attribute. _a_nofollow = re.compile(r"<(a)([^>]*href=)", re.IGNORECASE) def convert(self, text): """Convert the given text.""" # Main function. The order in which other subs are called here is # essential. Link and image substitutions need to happen before # _EscapeSpecialChars(), so that any *'s or _'s in the # and tags get encoded. # Clear the global hashes. If we don't clear these, you get conflicts # from other articles when generating a page which contains more than # one article (e.g. an index page that shows the N most recent # articles): self.reset() if not isinstance(text, unicode): #TODO: perhaps shouldn't presume UTF-8 for string input? text = unicode(text, 'utf-8') if self.use_file_vars: # Look for emacs-style file variable hints. emacs_vars = self._get_emacs_vars(text) if "markdown-extras" in emacs_vars: splitter = re.compile("[ ,]+") for e in splitter.split(emacs_vars["markdown-extras"]): if '=' in e: ename, earg = e.split('=', 1) try: earg = int(earg) except ValueError: pass else: ename, earg = e, None self.extras[ename] = earg # Standardize line endings: text = re.sub("\r\n|\r", "\n", text) # Make sure $text ends with a couple of newlines: text += "\n\n" # Convert all tabs to spaces. text = self._detab(text) # Strip any lines consisting only of spaces and tabs. # This makes subsequent regexen easier to write, because we can # match consecutive blank lines with /\n+/ instead of something # contorted like /[ \t]*\n+/ . text = self._ws_only_line_re.sub("", text) # strip metadata from head and extract if "metadata" in self.extras: text = self._extract_metadata(text) text = self.preprocess(text) if self.safe_mode: text = self._hash_html_spans(text) # Turn block-level HTML blocks into hash entries text = self._hash_html_blocks(text, raw=True) # Strip link definitions, store in hashes. if "footnotes" in self.extras: # Must do footnotes first because an unlucky footnote defn # looks like a link defn: # [^4]: this "looks like a link defn" text = self._strip_footnote_definitions(text) text = self._strip_link_definitions(text) text = self._run_block_gamut(text) if "footnotes" in self.extras: text = self._add_footnotes(text) text = self.postprocess(text) text = self._unescape_special_chars(text) if self.safe_mode: text = self._unhash_html_spans(text) if "nofollow" in self.extras: text = self._a_nofollow.sub(r'<\1 rel="nofollow"\2', text) text += "\n" rv = UnicodeWithAttrs(text) if "toc" in self.extras: rv._toc = self._toc if "metadata" in self.extras: rv.metadata = self.metadata return rv def postprocess(self, text): """A hook for subclasses to do some postprocessing of the html, if desired. This is called before unescaping of special chars and unhashing of raw HTML spans. """ return text def preprocess(self, text): """A hook for subclasses to do some preprocessing of the Markdown, if desired. This is called after basic formatting of the text, but prior to any extras, safe mode, etc. processing. """ return text # Is metadata if the content starts with '---'-fenced `key: value` # pairs. E.g. (indented for presentation): # --- # foo: bar # another-var: blah blah # --- _metadata_pat = re.compile("""^---[ \t]*\n((?:[ \t]*[^ \t:]+[ \t]*:[^\n]*\n)+)---[ \t]*\n""") def _extract_metadata(self, text): # fast test if not text.startswith("---"): return text match = self._metadata_pat.match(text) if not match: return text tail = text[len(match.group(0)):] metadata_str = match.group(1).strip() for line in metadata_str.split('\n'): key, value = line.split(':', 1) self.metadata[key.strip()] = value.strip() return tail _emacs_oneliner_vars_pat = re.compile(r"-\*-\s*([^\r\n]*?)\s*-\*-", re.UNICODE) # This regular expression is intended to match blocks like this: # PREFIX Local Variables: SUFFIX # PREFIX mode: Tcl SUFFIX # PREFIX End: SUFFIX # Some notes: # - "[ \t]" is used instead of "\s" to specifically exclude newlines # - "(\r\n|\n|\r)" is used instead of "$" because the sre engine does # not like anything other than Unix-style line terminators. _emacs_local_vars_pat = re.compile(r"""^ (?P(?:[^\r\n|\n|\r])*?) [\ \t]*Local\ Variables:[\ \t]* (?P.*?)(?:\r\n|\n|\r) (?P.*?\1End:) """, re.IGNORECASE | re.MULTILINE | re.DOTALL | re.VERBOSE) def _get_emacs_vars(self, text): """Return a dictionary of emacs-style local variables. Parsing is done loosely according to this spec (and according to some in-practice deviations from this): http://www.gnu.org/software/emacs/manual/html_node/emacs/Specifying-File-Variables.html#Specifying-File-Variables """ emacs_vars = {} SIZE = pow(2, 13) # 8kB # Search near the start for a '-*-'-style one-liner of variables. head = text[:SIZE] if "-*-" in head: match = self._emacs_oneliner_vars_pat.search(head) if match: emacs_vars_str = match.group(1) assert '\n' not in emacs_vars_str emacs_var_strs = [s.strip() for s in emacs_vars_str.split(';') if s.strip()] if len(emacs_var_strs) == 1 and ':' not in emacs_var_strs[0]: # While not in the spec, this form is allowed by emacs: # -*- Tcl -*- # where the implied "variable" is "mode". This form # is only allowed if there are no other variables. emacs_vars["mode"] = emacs_var_strs[0].strip() else: for emacs_var_str in emacs_var_strs: try: variable, value = emacs_var_str.strip().split(':', 1) except ValueError: log.debug("emacs variables error: malformed -*- " "line: %r", emacs_var_str) continue # Lowercase the variable name because Emacs allows "Mode" # or "mode" or "MoDe", etc. emacs_vars[variable.lower()] = value.strip() tail = text[-SIZE:] if "Local Variables" in tail: match = self._emacs_local_vars_pat.search(tail) if match: prefix = match.group("prefix") suffix = match.group("suffix") lines = match.group("content").splitlines(0) #print "prefix=%r, suffix=%r, content=%r, lines: %s"\ # % (prefix, suffix, match.group("content"), lines) # Validate the Local Variables block: proper prefix and suffix # usage. for i, line in enumerate(lines): if not line.startswith(prefix): log.debug("emacs variables error: line '%s' " "does not use proper prefix '%s'" % (line, prefix)) return {} # Don't validate suffix on last line. Emacs doesn't care, # neither should we. if i != len(lines)-1 and not line.endswith(suffix): log.debug("emacs variables error: line '%s' " "does not use proper suffix '%s'" % (line, suffix)) return {} # Parse out one emacs var per line. continued_for = None for line in lines[:-1]: # no var on the last line ("PREFIX End:") if prefix: line = line[len(prefix):] # strip prefix if suffix: line = line[:-len(suffix)] # strip suffix line = line.strip() if continued_for: variable = continued_for if line.endswith('\\'): line = line[:-1].rstrip() else: continued_for = None emacs_vars[variable] += ' ' + line else: try: variable, value = line.split(':', 1) except ValueError: log.debug("local variables error: missing colon " "in local variables entry: '%s'" % line) continue # Do NOT lowercase the variable name, because Emacs only # allows "mode" (and not "Mode", "MoDe", etc.) in this block. value = value.strip() if value.endswith('\\'): value = value[:-1].rstrip() continued_for = variable else: continued_for = None emacs_vars[variable] = value # Unquote values. for var, val in list(emacs_vars.items()): if len(val) > 1 and (val.startswith('"') and val.endswith('"') or val.startswith('"') and val.endswith('"')): emacs_vars[var] = val[1:-1] return emacs_vars # Cribbed from a post by Bart Lateur: # _detab_re = re.compile(r'(.*?)\t', re.M) def _detab_sub(self, match): g1 = match.group(1) return g1 + (' ' * (self.tab_width - len(g1) % self.tab_width)) def _detab(self, text): r"""Remove (leading?) tabs from a file. >>> m = Markdown() >>> m._detab("\tfoo") ' foo' >>> m._detab(" \tfoo") ' foo' >>> m._detab("\t foo") ' foo' >>> m._detab(" foo") ' foo' >>> m._detab(" foo\n\tbar\tblam") ' foo\n bar blam' """ if '\t' not in text: return text return self._detab_re.subn(self._detab_sub, text)[0] # I broke out the html5 tags here and add them to _block_tags_a and # _block_tags_b. This way html5 tags are easy to keep track of. _html5tags = '|article|aside|header|hgroup|footer|nav|section|figure|figcaption' _block_tags_a = 'p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math|ins|del' _block_tags_a += _html5tags _strict_tag_block_re = re.compile(r""" ( # save in \1 ^ # start of line (with re.M) <(%s) # start tag = \2 \b # word break (.*\n)*? # any number of lines, minimally matching # the matching end tag [ \t]* # trailing spaces/tabs (?=\n+|\Z) # followed by a newline or end of document ) """ % _block_tags_a, re.X | re.M) _block_tags_b = 'p|div|h[1-6]|blockquote|pre|table|dl|ol|ul|script|noscript|form|fieldset|iframe|math' _block_tags_b += _html5tags _liberal_tag_block_re = re.compile(r""" ( # save in \1 ^ # start of line (with re.M) <(%s) # start tag = \2 \b # word break (.*\n)*? # any number of lines, minimally matching .* # the matching end tag [ \t]* # trailing spaces/tabs (?=\n+|\Z) # followed by a newline or end of document ) """ % _block_tags_b, re.X | re.M) _html_markdown_attr_re = re.compile( r'''\s+markdown=("1"|'1')''') def _hash_html_block_sub(self, match, raw=False): html = match.group(1) if raw and self.safe_mode: html = self._sanitize_html(html) elif 'markdown-in-html' in self.extras and 'markdown=' in html: first_line = html.split('\n', 1)[0] m = self._html_markdown_attr_re.search(first_line) if m: lines = html.split('\n') middle = '\n'.join(lines[1:-1]) last_line = lines[-1] first_line = first_line[:m.start()] + first_line[m.end():] f_key = _hash_text(first_line) self.html_blocks[f_key] = first_line l_key = _hash_text(last_line) self.html_blocks[l_key] = last_line return ''.join(["\n\n", f_key, "\n\n", middle, "\n\n", l_key, "\n\n"]) key = _hash_text(html) self.html_blocks[key] = html return "\n\n" + key + "\n\n" def _hash_html_blocks(self, text, raw=False): """Hashify HTML blocks We only want to do this for block-level HTML tags, such as headers, lists, and tables. That's because we still want to wrap

s around "paragraphs" that are wrapped in non-block-level tags, such as anchors, phrase emphasis, and spans. The list of tags we're looking for is hard-coded. @param raw {boolean} indicates if these are raw HTML blocks in the original source. It makes a difference in "safe" mode. """ if '<' not in text: return text # Pass `raw` value into our calls to self._hash_html_block_sub. hash_html_block_sub = _curry(self._hash_html_block_sub, raw=raw) # First, look for nested blocks, e.g.: #

#
# tags for inner block must be indented. #
#
# # The outermost tags must start at the left margin for this to match, and # the inner nested divs must be indented. # We need to do this before the next, more liberal match, because the next # match will start at the first `
` and stop at the first `
`. text = self._strict_tag_block_re.sub(hash_html_block_sub, text) # Now match more liberally, simply from `\n` to `\n` text = self._liberal_tag_block_re.sub(hash_html_block_sub, text) # Special case just for
. It was easier to make a special # case than to make the other regex more complicated. if "", start_idx) + 3 except ValueError: break # Start position for next comment block search. start = end_idx # Validate whitespace before comment. if start_idx: # - Up to `tab_width - 1` spaces before start_idx. for i in range(self.tab_width - 1): if text[start_idx - 1] != ' ': break start_idx -= 1 if start_idx == 0: break # - Must be preceded by 2 newlines or hit the start of # the document. if start_idx == 0: pass elif start_idx == 1 and text[0] == '\n': start_idx = 0 # to match minute detail of Markdown.pl regex elif text[start_idx-2:start_idx] == '\n\n': pass else: break # Validate whitespace after comment. # - Any number of spaces and tabs. while end_idx < len(text): if text[end_idx] not in ' \t': break end_idx += 1 # - Must be following by 2 newlines or hit end of text. if text[end_idx:end_idx+2] not in ('', '\n', '\n\n'): continue # Escape and hash (must match `_hash_html_block_sub`). html = text[start_idx:end_idx] if raw and self.safe_mode: html = self._sanitize_html(html) key = _hash_text(html) self.html_blocks[key] = html text = text[:start_idx] + "\n\n" + key + "\n\n" + text[end_idx:] if "xml" in self.extras: # Treat XML processing instructions and namespaced one-liner # tags as if they were block HTML tags. E.g., if standalone # (i.e. are their own paragraph), the following do not get # wrapped in a

tag: # # # _xml_oneliner_re = _xml_oneliner_re_from_tab_width(self.tab_width) text = _xml_oneliner_re.sub(hash_html_block_sub, text) return text def _strip_link_definitions(self, text): # Strips link definitions from text, stores the URLs and titles in # hash references. less_than_tab = self.tab_width - 1 # Link defs are in the form: # [id]: url "optional title" _link_def_re = re.compile(r""" ^[ ]{0,%d}\[(.+)\]: # id = \1 [ \t]* \n? # maybe *one* newline [ \t]* ? # url = \2 [ \t]* (?: \n? # maybe one newline [ \t]* (?<=\s) # lookbehind for whitespace ['"(] ([^\n]*) # title = \3 ['")] [ \t]* )? # title is optional (?:\n+|\Z) """ % less_than_tab, re.X | re.M | re.U) return _link_def_re.sub(self._extract_link_def_sub, text) def _extract_link_def_sub(self, match): id, url, title = match.groups() key = id.lower() # Link IDs are case-insensitive self.urls[key] = self._encode_amps_and_angles(url) if title: self.titles[key] = title return "" def _extract_footnote_def_sub(self, match): id, text = match.groups() text = _dedent(text, skip_first_line=not text.startswith('\n')).strip() normed_id = re.sub(r'\W', '-', id) # Ensure footnote text ends with a couple newlines (for some # block gamut matches). self.footnotes[normed_id] = text + "\n\n" return "" def _strip_footnote_definitions(self, text): """A footnote definition looks like this: [^note-id]: Text of the note. May include one or more indented paragraphs. Where, - The 'note-id' can be pretty much anything, though typically it is the number of the footnote. - The first paragraph may start on the next line, like so: [^note-id]: Text of the note. """ less_than_tab = self.tab_width - 1 footnote_def_re = re.compile(r''' ^[ ]{0,%d}\[\^(.+)\]: # id = \1 [ \t]* ( # footnote text = \2 # First line need not start with the spaces. (?:\s*.*\n+) (?: (?:[ ]{%d} | \t) # Subsequent lines must be indented. .*\n+ )* ) # Lookahead for non-space at line-start, or end of doc. (?:(?=^[ ]{0,%d}\S)|\Z) ''' % (less_than_tab, self.tab_width, self.tab_width), re.X | re.M) return footnote_def_re.sub(self._extract_footnote_def_sub, text) _hr_data = [ ('*', re.compile(r"^[ ]{0,3}\*(.*?)$", re.M)), ('-', re.compile(r"^[ ]{0,3}\-(.*?)$", re.M)), ('_', re.compile(r"^[ ]{0,3}\_(.*?)$", re.M)), ] def _run_block_gamut(self, text): # These are all the transformations that form block-level # tags like paragraphs, headers, and list items. if "fenced-code-blocks" in self.extras: text = self._do_fenced_code_blocks(text) text = self._do_headers(text) # Do Horizontal Rules: # On the number of spaces in horizontal rules: The spec is fuzzy: "If # you wish, you may use spaces between the hyphens or asterisks." # Markdown.pl 1.0.1's hr regexes limit the number of spaces between the # hr chars to one or two. We'll reproduce that limit here. hr = "\n tags around block-level tags. text = self._hash_html_blocks(text) text = self._form_paragraphs(text) return text def _pyshell_block_sub(self, match): lines = match.group(0).splitlines(0) _dedentlines(lines) indent = ' ' * self.tab_width s = ('\n' # separate from possible cuddled paragraph + indent + ('\n'+indent).join(lines) + '\n\n') return s def _prepare_pyshell_blocks(self, text): """Ensure that Python interactive shell sessions are put in code blocks -- even if not properly indented. """ if ">>>" not in text: return text less_than_tab = self.tab_width - 1 _pyshell_block_re = re.compile(r""" ^([ ]{0,%d})>>>[ ].*\n # first line ^(\1.*\S+.*\n)* # any number of subsequent lines ^\n # ends with a blank line """ % less_than_tab, re.M | re.X) return _pyshell_block_re.sub(self._pyshell_block_sub, text) def _wiki_table_sub(self, match): ttext = match.group(0).strip() #print 'wiki table: %r' % match.group(0) rows = [] for line in ttext.splitlines(0): line = line.strip()[2:-2].strip() row = [c.strip() for c in re.split(r'(?', '

'] for row in rows: hrow = [''] for cell in row: hrow.append('') hrow.append('') hlines.append(''.join(hrow)) hlines += ['', '
') hrow.append(self._run_span_gamut(cell)) hrow.append('
'] return '\n'.join(hlines) + '\n' def _do_wiki_tables(self, text): # Optimization. if "||" not in text: return text less_than_tab = self.tab_width - 1 wiki_table_re = re.compile(r''' (?:(?<=\n\n)|\A\n?) # leading blank line ^([ ]{0,%d})\|\|.+?\|\|[ ]*\n # first line (^\1\|\|.+?\|\|\n)* # any number of subsequent lines ''' % less_than_tab, re.M | re.X) return wiki_table_re.sub(self._wiki_table_sub, text) def _run_span_gamut(self, text): # These are all the transformations that occur *within* block-level # tags like paragraphs, headers, and list items. text = self._do_code_spans(text) text = self._escape_special_chars(text) # Process anchor and image tags. text = self._do_links(text) # Make links out of things like `` # Must come after _do_links(), because you can use < and > # delimiters in inline links like [this](). text = self._do_auto_links(text) if "link-patterns" in self.extras: text = self._do_link_patterns(text) text = self._encode_amps_and_angles(text) text = self._do_italics_and_bold(text) if "smarty-pants" in self.extras: text = self._do_smart_punctuation(text) # Do hard breaks: text = re.sub(r" {2,}\n", " | # auto-link (e.g., ) <\w+[^>]*> | # comment | <\?.*?\?> # processing instruction ) """, re.X) def _escape_special_chars(self, text): # Python markdown note: the HTML tokenization here differs from # that in Markdown.pl, hence the behaviour for subtle cases can # differ (I believe the tokenizer here does a better job because # it isn't susceptible to unmatched '<' and '>' in HTML tags). # Note, however, that '>' is not allowed in an auto-link URL # here. escaped = [] is_html_markup = False for token in self._sorta_html_tokenize_re.split(text): if is_html_markup: # Within tags/HTML-comments/auto-links, encode * and _ # so they don't conflict with their use in Markdown for # italics and strong. We're replacing each such # character with its corresponding MD5 checksum value; # this is likely overkill, but it should prevent us from # colliding with the escape values by accident. escaped.append(token.replace('*', self._escape_table['*']) .replace('_', self._escape_table['_'])) else: escaped.append(self._encode_backslash_escapes(token)) is_html_markup = not is_html_markup return ''.join(escaped) def _hash_html_spans(self, text): # Used for safe_mode. def _is_auto_link(s): if ':' in s and self._auto_link_re.match(s): return True elif '@' in s and self._auto_email_link_re.match(s): return True return False tokens = [] is_html_markup = False for token in self._sorta_html_tokenize_re.split(text): if is_html_markup and not _is_auto_link(token): sanitized = self._sanitize_html(token) key = _hash_text(sanitized) self.html_spans[key] = sanitized tokens.append(key) else: tokens.append(token) is_html_markup = not is_html_markup return ''.join(tokens) def _unhash_html_spans(self, text): for key, sanitized in list(self.html_spans.items()): text = text.replace(key, sanitized) return text def _sanitize_html(self, s): if self.safe_mode == "replace": return self.html_removed_text elif self.safe_mode == "escape": replacements = [ ('&', '&'), ('<', '<'), ('>', '>'), ] for before, after in replacements: s = s.replace(before, after) return s else: raise MarkdownError("invalid value for 'safe_mode': %r (must be " "'escape' or 'replace')" % self.safe_mode) _tail_of_inline_link_re = re.compile(r''' # Match tail of: [text](/url/) or [text](/url/ "title") \( # literal paren [ \t]* (?P # \1 <.*?> | .*? ) [ \t]* ( # \2 (['"]) # quote char = \3 (?P.*?) \3 # matching quote )? # title is optional \) ''', re.X | re.S) _tail_of_reference_link_re = re.compile(r''' # Match tail of: [text][id] [ ]? # one optional space (?:\n[ ]*)? # one optional newline followed by spaces \[ (?P<id>.*?) \] ''', re.X | re.S) def _do_links(self, text): """Turn Markdown link shortcuts into XHTML <a> and <img> tags. This is a combination of Markdown.pl's _DoAnchors() and _DoImages(). They are done together because that simplified the approach. It was necessary to use a different approach than Markdown.pl because of the lack of atomic matching support in Python's regex engine used in $g_nested_brackets. """ MAX_LINK_TEXT_SENTINEL = 3000 # markdown2 issue 24 # `anchor_allowed_pos` is used to support img links inside # anchors, but not anchors inside anchors. An anchor's start # pos must be `>= anchor_allowed_pos`. anchor_allowed_pos = 0 curr_pos = 0 while True: # Handle the next link. # The next '[' is the start of: # - an inline anchor: [text](url "title") # - a reference anchor: [text][id] # - an inline img: ![text](url "title") # - a reference img: ![text][id] # - a footnote ref: [^id] # (Only if 'footnotes' extra enabled) # - a footnote defn: [^id]: ... # (Only if 'footnotes' extra enabled) These have already # been stripped in _strip_footnote_definitions() so no # need to watch for them. # - a link definition: [id]: url "title" # These have already been stripped in # _strip_link_definitions() so no need to watch for them. # - not markup: [...anything else... try: start_idx = text.index('[', curr_pos) except ValueError: break text_length = len(text) # Find the matching closing ']'. # Markdown.pl allows *matching* brackets in link text so we # will here too. Markdown.pl *doesn't* currently allow # matching brackets in img alt text -- we'll differ in that # regard. bracket_depth = 0 for p in range(start_idx+1, min(start_idx+MAX_LINK_TEXT_SENTINEL, text_length)): ch = text[p] if ch == ']': bracket_depth -= 1 if bracket_depth < 0: break elif ch == '[': bracket_depth += 1 else: # Closing bracket not found within sentinel length. # This isn't markup. curr_pos = start_idx + 1 continue link_text = text[start_idx+1:p] # Possibly a footnote ref? if "footnotes" in self.extras and link_text.startswith("^"): normed_id = re.sub(r'\W', '-', link_text[1:]) if normed_id in self.footnotes: self.footnote_ids.append(normed_id) result = '<sup class="footnote-ref" id="fnref-%s">' \ '<a href="#fn-%s">%s</a></sup>' \ % (normed_id, normed_id, len(self.footnote_ids)) text = text[:start_idx] + result + text[p+1:] else: # This id isn't defined, leave the markup alone. curr_pos = p+1 continue # Now determine what this is by the remainder. p += 1 if p == text_length: return text # Inline anchor or img? if text[p] == '(': # attempt at perf improvement match = self._tail_of_inline_link_re.match(text, p) if match: # Handle an inline anchor or img. is_img = start_idx > 0 and text[start_idx-1] == "!" if is_img: start_idx -= 1 url, title = match.group("url"), match.group("title") if url and url[0] == '<': url = url[1:-1] # '<url>' -> 'url' # We've got to encode these to avoid conflicting # with italics/bold. url = url.replace('*', self._escape_table['*']) \ .replace('_', self._escape_table['_']) if title: title_str = ' title="%s"' % ( _xml_escape_attr(title) .replace('*', self._escape_table['*']) .replace('_', self._escape_table['_'])) else: title_str = '' if is_img: result = '<img src="%s" alt="%s"%s%s' \ % (url.replace('"', '"'), _xml_escape_attr(link_text), title_str, self.empty_element_suffix) if "smarty-pants" in self.extras: result = result.replace('"', self._escape_table['"']) curr_pos = start_idx + len(result) text = text[:start_idx] + result + text[match.end():] elif start_idx >= anchor_allowed_pos: result_head = '<a href="%s"%s>' % (url, title_str) result = '%s%s</a>' % (result_head, link_text) if "smarty-pants" in self.extras: result = result.replace('"', self._escape_table['"']) # <img> allowed from curr_pos on, <a> from # anchor_allowed_pos on. curr_pos = start_idx + len(result_head) anchor_allowed_pos = start_idx + len(result) text = text[:start_idx] + result + text[match.end():] else: # Anchor not allowed here. curr_pos = start_idx + 1 continue # Reference anchor or img? else: match = self._tail_of_reference_link_re.match(text, p) if match: # Handle a reference-style anchor or img. is_img = start_idx > 0 and text[start_idx-1] == "!" if is_img: start_idx -= 1 link_id = match.group("id").lower() if not link_id: link_id = link_text.lower() # for links like [this][] if link_id in self.urls: url = self.urls[link_id] # We've got to encode these to avoid conflicting # with italics/bold. url = url.replace('*', self._escape_table['*']) \ .replace('_', self._escape_table['_']) title = self.titles.get(link_id) if title: before = title title = _xml_escape_attr(title) \ .replace('*', self._escape_table['*']) \ .replace('_', self._escape_table['_']) title_str = ' title="%s"' % title else: title_str = '' if is_img: result = '<img src="%s" alt="%s"%s%s' \ % (url.replace('"', '"'), link_text.replace('"', '"'), title_str, self.empty_element_suffix) if "smarty-pants" in self.extras: result = result.replace('"', self._escape_table['"']) curr_pos = start_idx + len(result) text = text[:start_idx] + result + text[match.end():] elif start_idx >= anchor_allowed_pos: result = '<a href="%s"%s>%s</a>' \ % (url, title_str, link_text) result_head = '<a href="%s"%s>' % (url, title_str) result = '%s%s</a>' % (result_head, link_text) if "smarty-pants" in self.extras: result = result.replace('"', self._escape_table['"']) # <img> allowed from curr_pos on, <a> from # anchor_allowed_pos on. curr_pos = start_idx + len(result_head) anchor_allowed_pos = start_idx + len(result) text = text[:start_idx] + result + text[match.end():] else: # Anchor not allowed here. curr_pos = start_idx + 1 else: # This id isn't defined, leave the markup alone. curr_pos = match.end() continue # Otherwise, it isn't markup. curr_pos = start_idx + 1 return text def header_id_from_text(self, text, prefix, n): """Generate a header id attribute value from the given header HTML content. This is only called if the "header-ids" extra is enabled. Subclasses may override this for different header ids. @param text {str} The text of the header tag @param prefix {str} The requested prefix for header ids. This is the value of the "header-ids" extra key, if any. Otherwise, None. @param n {int} The <hN> tag number, i.e. `1` for an <h1> tag. @returns {str} The value for the header tag's "id" attribute. Return None to not have an id attribute and to exclude this header from the TOC (if the "toc" extra is specified). """ header_id = _slugify(text) if prefix and isinstance(prefix, base_string_type): header_id = prefix + '-' + header_id if header_id in self._count_from_header_id: self._count_from_header_id[header_id] += 1 header_id += '-%s' % self._count_from_header_id[header_id] else: self._count_from_header_id[header_id] = 1 return header_id _toc = None def _toc_add_entry(self, level, id, name): if self._toc is None: self._toc = [] self._toc.append((level, id, self._unescape_special_chars(name))) _setext_h_re = re.compile(r'^(.+)[ \t]*\n(=+|-+)[ \t]*\n+', re.M) def _setext_h_sub(self, match): n = {"=": 1, "-": 2}[match.group(2)[0]] demote_headers = self.extras.get("demote-headers") if demote_headers: n = min(n + demote_headers, 6) header_id_attr = "" if "header-ids" in self.extras: header_id = self.header_id_from_text(match.group(1), self.extras["header-ids"], n) if header_id: header_id_attr = ' id="%s"' % header_id html = self._run_span_gamut(match.group(1)) if "toc" in self.extras and header_id: self._toc_add_entry(n, header_id, html) return "<h%d%s>%s</h%d>\n\n" % (n, header_id_attr, html, n) _atx_h_re = re.compile(r''' ^(\#{1,6}) # \1 = string of #'s [ \t]+ (.+?) # \2 = Header text [ \t]* (?<!\\) # ensure not an escaped trailing '#' \#* # optional closing #'s (not counted) \n+ ''', re.X | re.M) def _atx_h_sub(self, match): n = len(match.group(1)) demote_headers = self.extras.get("demote-headers") if demote_headers: n = min(n + demote_headers, 6) header_id_attr = "" if "header-ids" in self.extras: header_id = self.header_id_from_text(match.group(2), self.extras["header-ids"], n) if header_id: header_id_attr = ' id="%s"' % header_id html = self._run_span_gamut(match.group(2)) if "toc" in self.extras and header_id: self._toc_add_entry(n, header_id, html) return "<h%d%s>%s</h%d>\n\n" % (n, header_id_attr, html, n) def _do_headers(self, text): # Setext-style headers: # Header 1 # ======== # # Header 2 # -------- text = self._setext_h_re.sub(self._setext_h_sub, text) # atx-style headers: # # Header 1 # ## Header 2 # ## Header 2 with closing hashes ## # ... # ###### Header 6 text = self._atx_h_re.sub(self._atx_h_sub, text) return text _marker_ul_chars = '*+-' _marker_any = r'(?:[%s]|\d+\.)' % _marker_ul_chars _marker_ul = '(?:[%s])' % _marker_ul_chars _marker_ol = r'(?:\d+\.)' def _list_sub(self, match): lst = match.group(1) lst_type = match.group(3) in self._marker_ul_chars and "ul" or "ol" result = self._process_list_items(lst) if self.list_level: return "<%s>\n%s</%s>\n" % (lst_type, result, lst_type) else: return "<%s>\n%s</%s>\n\n" % (lst_type, result, lst_type) def _do_lists(self, text): # Form HTML ordered (numbered) and unordered (bulleted) lists. # Iterate over each *non-overlapping* list match. pos = 0 while True: # Find the *first* hit for either list style (ul or ol). We # match ul and ol separately to avoid adjacent lists of different # types running into each other (see issue #16). hits = [] for marker_pat in (self._marker_ul, self._marker_ol): less_than_tab = self.tab_width - 1 whole_list = r''' ( # \1 = whole list ( # \2 [ ]{0,%d} (%s) # \3 = first list item marker [ \t]+ (?!\ *\3\ ) # '- - - ...' isn't a list. See 'not_quite_a_list' test case. ) (?:.+?) ( # \4 \Z | \n{2,} (?=\S) (?! # Negative lookahead for another list item marker [ \t]* %s[ \t]+ ) ) ) ''' % (less_than_tab, marker_pat, marker_pat) if self.list_level: # sub-list list_re = re.compile("^"+whole_list, re.X | re.M | re.S) else: list_re = re.compile(r"(?:(?<=\n\n)|\A\n?)"+whole_list, re.X | re.M | re.S) match = list_re.search(text, pos) if match: hits.append((match.start(), match)) if not hits: break hits.sort() match = hits[0][1] start, end = match.span() text = text[:start] + self._list_sub(match) + text[end:] pos = end return text _list_item_re = re.compile(r''' (\n)? # leading line = \1 (^[ \t]*) # leading whitespace = \2 (?P<marker>%s) [ \t]+ # list marker = \3 ((?:.+?) # list item text = \4 (\n{1,2})) # eols = \5 (?= \n* (\Z | \2 (?P<next_marker>%s) [ \t]+)) ''' % (_marker_any, _marker_any), re.M | re.X | re.S) _last_li_endswith_two_eols = False def _list_item_sub(self, match): item = match.group(4) leading_line = match.group(1) leading_space = match.group(2) if leading_line or "\n\n" in item or self._last_li_endswith_two_eols: item = self._run_block_gamut(self._outdent(item)) else: # Recursion for sub-lists: item = self._do_lists(self._outdent(item)) if item.endswith('\n'): item = item[:-1] item = self._run_span_gamut(item) self._last_li_endswith_two_eols = (len(match.group(5)) == 2) return "<li>%s</li>\n" % item def _process_list_items(self, list_str): # Process the contents of a single ordered or unordered list, # splitting it into individual list items. # The $g_list_level global keeps track of when we're inside a list. # Each time we enter a list, we increment it; when we leave a list, # we decrement. If it's zero, we're not in a list anymore. # # We do this because when we're not inside a list, we want to treat # something like this: # # I recommend upgrading to version # 8. Oops, now this line is treated # as a sub-list. # # As a single paragraph, despite the fact that the second line starts # with a digit-period-space sequence. # # Whereas when we're inside a list (or sub-list), that line will be # treated as the start of a sub-list. What a kludge, huh? This is # an aspect of Markdown's syntax that's hard to parse perfectly # without resorting to mind-reading. Perhaps the solution is to # change the syntax rules such that sub-lists must start with a # starting cardinal number; e.g. "1." or "a.". self.list_level += 1 self._last_li_endswith_two_eols = False list_str = list_str.rstrip('\n') + '\n' list_str = self._list_item_re.sub(self._list_item_sub, list_str) self.list_level -= 1 return list_str def _get_pygments_lexer(self, lexer_name): try: from pygments import lexers, util except ImportError: return None try: return lexers.get_lexer_by_name(lexer_name) except util.ClassNotFound: return None def _color_with_pygments(self, codeblock, lexer, **formatter_opts): import pygments import pygments.formatters class HtmlCodeFormatter(pygments.formatters.HtmlFormatter): def _wrap_code(self, inner): """A function for use in a Pygments Formatter which wraps in <code> tags. """ yield 0, "<code>" for tup in inner: yield tup yield 0, "</code>" def wrap(self, source, outfile): """Return the source with a code, pre, and div.""" return self._wrap_div(self._wrap_pre(self._wrap_code(source))) formatter_opts.setdefault("cssclass", "codehilite") formatter = HtmlCodeFormatter(**formatter_opts) return pygments.highlight(codeblock, lexer, formatter) def _code_block_sub(self, match, is_fenced_code_block=False): lexer_name = None if is_fenced_code_block: lexer_name = match.group(1) if lexer_name: formatter_opts = self.extras['fenced-code-blocks'] or {} codeblock = match.group(2) codeblock = codeblock[:-1] # drop one trailing newline else: codeblock = match.group(1) codeblock = self._outdent(codeblock) codeblock = self._detab(codeblock) codeblock = codeblock.lstrip('\n') # trim leading newlines codeblock = codeblock.rstrip() # trim trailing whitespace # Note: "code-color" extra is DEPRECATED. if "code-color" in self.extras and codeblock.startswith(":::"): lexer_name, rest = codeblock.split('\n', 1) lexer_name = lexer_name[3:].strip() codeblock = rest.lstrip("\n") # Remove lexer declaration line. formatter_opts = self.extras['code-color'] or {} if lexer_name: lexer = self._get_pygments_lexer(lexer_name) if lexer: colored = self._color_with_pygments(codeblock, lexer, **formatter_opts) return "\n\n%s\n\n" % colored codeblock = self._encode_code(codeblock) pre_class_str = self._html_class_str_from_tag("pre") code_class_str = self._html_class_str_from_tag("code") return "\n\n<pre%s><code%s>%s\n</code></pre>\n\n" % ( pre_class_str, code_class_str, codeblock) def _html_class_str_from_tag(self, tag): """Get the appropriate ' class="..."' string (note the leading space), if any, for the given tag. """ if "html-classes" not in self.extras: return "" try: html_classes_from_tag = self.extras["html-classes"] except TypeError: return "" else: if tag in html_classes_from_tag: return ' class="%s"' % html_classes_from_tag[tag] return "" def _do_code_blocks(self, text): """Process Markdown `<pre><code>` blocks.""" code_block_re = re.compile(r''' (?:\n\n|\A\n?) ( # $1 = the code block -- one or more lines, starting with a space/tab (?: (?:[ ]{%d} | \t) # Lines must start with a tab or a tab-width of spaces .*\n+ )+ ) ((?=^[ ]{0,%d}\S)|\Z) # Lookahead for non-space at line-start, or end of doc ''' % (self.tab_width, self.tab_width), re.M | re.X) return code_block_re.sub(self._code_block_sub, text) _fenced_code_block_re = re.compile(r''' (?:\n\n|\A\n?) ^```([\w+-]+)?[ \t]*\n # opening fence, $1 = optional lang (.*?) # $2 = code block content ^```[ \t]*\n # closing fence ''', re.M | re.X | re.S) def _fenced_code_block_sub(self, match): return self._code_block_sub(match, is_fenced_code_block=True); def _do_fenced_code_blocks(self, text): """Process ```-fenced unindented code blocks ('fenced-code-blocks' extra).""" return self._fenced_code_block_re.sub(self._fenced_code_block_sub, text) # Rules for a code span: # - backslash escapes are not interpreted in a code span # - to include one or or a run of more backticks the delimiters must # be a longer run of backticks # - cannot start or end a code span with a backtick; pad with a # space and that space will be removed in the emitted HTML # See `test/tm-cases/escapes.text` for a number of edge-case # examples. _code_span_re = re.compile(r''' (?<!\\) (`+) # \1 = Opening run of ` (?!`) # See Note A test/tm-cases/escapes.text (.+?) # \2 = The code block (?<!`) \1 # Matching closer (?!`) ''', re.X | re.S) def _code_span_sub(self, match): c = match.group(2).strip(" \t") c = self._encode_code(c) return "<code>%s</code>" % c def _do_code_spans(self, text): # * Backtick quotes are used for <code></code> spans. # # * You can use multiple backticks as the delimiters if you want to # include literal backticks in the code span. So, this input: # # Just type ``foo `bar` baz`` at the prompt. # # Will translate to: # # <p>Just type <code>foo `bar` baz</code> at the prompt.</p> # # There's no arbitrary limit to the number of backticks you # can use as delimters. If you need three consecutive backticks # in your code, use four for delimiters, etc. # # * You can use spaces to get literal backticks at the edges: # # ... type `` `bar` `` ... # # Turns to: # # ... type <code>`bar`</code> ... return self._code_span_re.sub(self._code_span_sub, text) def _encode_code(self, text): """Encode/escape certain characters inside Markdown code runs. The point is that in code, these characters are literals, and lose their special Markdown meanings. """ replacements = [ # Encode all ampersands; HTML entities are not # entities within a Markdown code span. ('&', '&'), # Do the angle bracket song and dance: ('<', '<'), ('>', '>'), ] for before, after in replacements: text = text.replace(before, after) hashed = _hash_text(text) self._escape_table[text] = hashed return hashed _strong_re = re.compile(r"(\*\*|__)(?=\S)(.+?[*_]*)(?<=\S)\1", re.S) _em_re = re.compile(r"(\*|_)(?=\S)(.+?)(?<=\S)\1", re.S) _code_friendly_strong_re = re.compile(r"\*\*(?=\S)(.+?[*_]*)(?<=\S)\*\*", re.S) _code_friendly_em_re = re.compile(r"\*(?=\S)(.+?)(?<=\S)\*", re.S) def _do_italics_and_bold(self, text): # <strong> must go first: if "code-friendly" in self.extras: text = self._code_friendly_strong_re.sub(r"<strong>\1</strong>", text) text = self._code_friendly_em_re.sub(r"<em>\1</em>", text) else: text = self._strong_re.sub(r"<strong>\2</strong>", text) text = self._em_re.sub(r"<em>\2</em>", text) return text # "smarty-pants" extra: Very liberal in interpreting a single prime as an # apostrophe; e.g. ignores the fact that "round", "bout", "twer", and # "twixt" can be written without an initial apostrophe. This is fine because # using scare quotes (single quotation marks) is rare. _apostrophe_year_re = re.compile(r"'(\d\d)(?=(\s|,|;|\.|\?|!|$))") _contractions = ["tis", "twas", "twer", "neath", "o", "n", "round", "bout", "twixt", "nuff", "fraid", "sup"] def _do_smart_contractions(self, text): text = self._apostrophe_year_re.sub(r"’\1", text) for c in self._contractions: text = text.replace("'%s" % c, "’%s" % c) text = text.replace("'%s" % c.capitalize(), "’%s" % c.capitalize()) return text # Substitute double-quotes before single-quotes. _opening_single_quote_re = re.compile(r"(?<!\S)'(?=\S)") _opening_double_quote_re = re.compile(r'(?<!\S)"(?=\S)') _closing_single_quote_re = re.compile(r"(?<=\S)'") _closing_double_quote_re = re.compile(r'(?<=\S)"(?=(\s|,|;|\.|\?|!|$))') def _do_smart_punctuation(self, text): """Fancifies 'single quotes', "double quotes", and apostrophes. Converts --, ---, and ... into en dashes, em dashes, and ellipses. Inspiration is: <http://daringfireball.net/projects/smartypants/> See "test/tm-cases/smarty_pants.text" for a full discussion of the support here and <http://code.google.com/p/python-markdown2/issues/detail?id=42> for a discussion of some diversion from the original SmartyPants. """ if "'" in text: # guard for perf text = self._do_smart_contractions(text) text = self._opening_single_quote_re.sub("‘", text) text = self._closing_single_quote_re.sub("’", text) if '"' in text: # guard for perf text = self._opening_double_quote_re.sub("“", text) text = self._closing_double_quote_re.sub("”", text) text = text.replace("---", "—") text = text.replace("--", "–") text = text.replace("...", "…") text = text.replace(" . . . ", "…") text = text.replace(". . .", "…") return text _block_quote_re = re.compile(r''' ( # Wrap whole match in \1 ( ^[ \t]*>[ \t]? # '>' at the start of a line .+\n # rest of the first line (.+\n)* # subsequent consecutive lines \n* # blanks )+ ) ''', re.M | re.X) _bq_one_level_re = re.compile('^[ \t]*>[ \t]?', re.M); _html_pre_block_re = re.compile(r'(\s*<pre>.+?</pre>)', re.S) def _dedent_two_spaces_sub(self, match): return re.sub(r'(?m)^ ', '', match.group(1)) def _block_quote_sub(self, match): bq = match.group(1) bq = self._bq_one_level_re.sub('', bq) # trim one level of quoting bq = self._ws_only_line_re.sub('', bq) # trim whitespace-only lines bq = self._run_block_gamut(bq) # recurse bq = re.sub('(?m)^', ' ', bq) # These leading spaces screw with <pre> content, so we need to fix that: bq = self._html_pre_block_re.sub(self._dedent_two_spaces_sub, bq) return "<blockquote>\n%s\n</blockquote>\n\n" % bq def _do_block_quotes(self, text): if '>' not in text: return text return self._block_quote_re.sub(self._block_quote_sub, text) def _form_paragraphs(self, text): # Strip leading and trailing lines: text = text.strip('\n') # Wrap <p> tags. grafs = [] for i, graf in enumerate(re.split(r"\n{2,}", text)): if graf in self.html_blocks: # Unhashify HTML blocks grafs.append(self.html_blocks[graf]) else: cuddled_list = None if "cuddled-lists" in self.extras: # Need to put back trailing '\n' for `_list_item_re` # match at the end of the paragraph. li = self._list_item_re.search(graf + '\n') # Two of the same list marker in this paragraph: a likely # candidate for a list cuddled to preceding paragraph # text (issue 33). Note the `[-1]` is a quick way to # consider numeric bullets (e.g. "1." and "2.") to be # equal. if (li and len(li.group(2)) <= 3 and li.group("next_marker") and li.group("marker")[-1] == li.group("next_marker")[-1]): start = li.start() cuddled_list = self._do_lists(graf[start:]).rstrip("\n") assert cuddled_list.startswith("<ul>") or cuddled_list.startswith("<ol>") graf = graf[:start] # Wrap <p> tags. graf = self._run_span_gamut(graf) grafs.append("<p>" + graf.lstrip(" \t") + "</p>") if cuddled_list: grafs.append(cuddled_list) return "\n\n".join(grafs) def _add_footnotes(self, text): if self.footnotes: footer = [ '<div class="footnotes">', '<hr' + self.empty_element_suffix, '<ol>', ] for i, id in enumerate(self.footnote_ids): if i != 0: footer.append('') footer.append('<li id="fn-%s">' % id) footer.append(self._run_block_gamut(self.footnotes[id])) backlink = ('<a href="#fnref-%s" ' 'class="footnoteBackLink" ' 'title="Jump back to footnote %d in the text.">' '↩</a>' % (id, i+1)) if footer[-1].endswith("</p>"): footer[-1] = footer[-1][:-len("</p>")] \ + ' ' + backlink + "</p>" else: footer.append("\n<p>%s</p>" % backlink) footer.append('</li>') footer.append('</ol>') footer.append('</div>') return text + '\n\n' + '\n'.join(footer) else: return text # Ampersand-encoding based entirely on Nat Irons's Amputator MT plugin: # http://bumppo.net/projects/amputator/ _ampersand_re = re.compile(r'&(?!#?[xX]?(?:[0-9a-fA-F]+|\w+);)') _naked_lt_re = re.compile(r'<(?![a-z/?\$!])', re.I) _naked_gt_re = re.compile(r'''(?<![a-z0-9?!/'"-])>''', re.I) def _encode_amps_and_angles(self, text): # Smart processing for ampersands and angle brackets that need # to be encoded. text = self._ampersand_re.sub('&', text) # Encode naked <'s text = self._naked_lt_re.sub('<', text) # Encode naked >'s # Note: Other markdown implementations (e.g. Markdown.pl, PHP # Markdown) don't do this. text = self._naked_gt_re.sub('>', text) return text def _encode_backslash_escapes(self, text): for ch, escape in list(self._escape_table.items()): text = text.replace("\\"+ch, escape) return text _auto_link_re = re.compile(r'<((https?|ftp):[^\'">\s]+)>', re.I) def _auto_link_sub(self, match): g1 = match.group(1) return '<a href="%s">%s</a>' % (g1, g1) _auto_email_link_re = re.compile(r""" < (?:mailto:)? ( [-.\w]+ \@ [-\w]+(\.[-\w]+)*\.[a-z]+ ) > """, re.I | re.X | re.U) def _auto_email_link_sub(self, match): return self._encode_email_address( self._unescape_special_chars(match.group(1))) def _do_auto_links(self, text): text = self._auto_link_re.sub(self._auto_link_sub, text) text = self._auto_email_link_re.sub(self._auto_email_link_sub, text) return text def _encode_email_address(self, addr): # Input: an email address, e.g. "foo@example.com" # # Output: the email address as a mailto link, with each character # of the address encoded as either a decimal or hex entity, in # the hopes of foiling most address harvesting spam bots. E.g.: # # <a href="mailto:foo@e # xample.com">foo # @example.com</a> # # Based on a filter by Matthew Wickline, posted to the BBEdit-Talk # mailing list: <http://tinyurl.com/yu7ue> chars = [_xml_encode_email_char_at_random(ch) for ch in "mailto:" + addr] # Strip the mailto: from the visible part. addr = '<a href="%s">%s</a>' \ % (''.join(chars), ''.join(chars[7:])) return addr def _do_link_patterns(self, text): """Caveat emptor: there isn't much guarding against link patterns being formed inside other standard Markdown links, e.g. inside a [link def][like this]. Dev Notes: *Could* consider prefixing regexes with a negative lookbehind assertion to attempt to guard against this. """ link_from_hash = {} for regex, repl in self.link_patterns: replacements = [] for match in regex.finditer(text): if hasattr(repl, "__call__"): href = repl(match) else: href = match.expand(repl) replacements.append((match.span(), href)) for (start, end), href in reversed(replacements): escaped_href = ( href.replace('"', '"') # b/c of attr quote # To avoid markdown <em> and <strong>: .replace('*', self._escape_table['*']) .replace('_', self._escape_table['_'])) link = '<a href="%s">%s</a>' % (escaped_href, text[start:end]) hash = _hash_text(link) link_from_hash[hash] = link text = text[:start] + hash + text[end:] for hash, link in list(link_from_hash.items()): text = text.replace(hash, link) return text def _unescape_special_chars(self, text): # Swap back in all the special characters we've hidden. for ch, hash in list(self._escape_table.items()): text = text.replace(hash, ch) return text def _outdent(self, text): # Remove one level of line-leading tabs or spaces return self._outdent_re.sub('', text) class MarkdownWithExtras(Markdown): """A markdowner class that enables most extras: - footnotes - code-color (only has effect if 'pygments' Python module on path) These are not included: - pyshell (specific to Python-related documenting) - code-friendly (because it *disables* part of the syntax) - link-patterns (because you need to specify some actual link-patterns anyway) """ extras = ["footnotes", "code-color"] #---- internal support functions class UnicodeWithAttrs(unicode): """A subclass of unicode used for the return value of conversion to possibly attach some attributes. E.g. the "toc_html" attribute when the "toc" extra is used. """ metadata = None _toc = None def toc_html(self): """Return the HTML for the current TOC. This expects the `_toc` attribute to have been set on this instance. """ if self._toc is None: return None def indent(): return ' ' * (len(h_stack) - 1) lines = [] h_stack = [0] # stack of header-level numbers for level, id, name in self._toc: if level > h_stack[-1]: lines.append("%s<ul>" % indent()) h_stack.append(level) elif level == h_stack[-1]: lines[-1] += "</li>" else: while level < h_stack[-1]: h_stack.pop() if not lines[-1].endswith("</li>"): lines[-1] += "</li>" lines.append("%s</ul></li>" % indent()) lines.append('%s<li><a href="#%s">%s</a>' % ( indent(), id, name)) while len(h_stack) > 1: h_stack.pop() if not lines[-1].endswith("</li>"): lines[-1] += "</li>" lines.append("%s</ul>" % indent()) return '\n'.join(lines) + '\n' toc_html = property(toc_html) ## {{{ http://code.activestate.com/recipes/577257/ (r1) import re char_map = {u'À': 'A', u'Á': 'A', u'Â': 'A', u'Ã': 'A', u'Ä': 'Ae', u'Å': 'A', u'Æ': 'A', u'Ā': 'A', u'Ą': 'A', u'Ă': 'A', u'Ç': 'C', u'Ć': 'C', u'Č': 'C', u'Ĉ': 'C', u'Ċ': 'C', u'Ď': 'D', u'Đ': 'D', u'È': 'E', u'É': 'E', u'Ê': 'E', u'Ë': 'E', u'Ē': 'E', u'Ę': 'E', u'Ě': 'E', u'Ĕ': 'E', u'Ė': 'E', u'Ĝ': 'G', u'Ğ': 'G', u'Ġ': 'G', u'Ģ': 'G', u'Ĥ': 'H', u'Ħ': 'H', u'Ì': 'I', u'Í': 'I', u'Î': 'I', u'Ï': 'I', u'Ī': 'I', u'Ĩ': 'I', u'Ĭ': 'I', u'Į': 'I', u'İ': 'I', u'IJ': 'IJ', u'Ĵ': 'J', u'Ķ': 'K', u'Ľ': 'K', u'Ĺ': 'K', u'Ļ': 'K', u'Ŀ': 'K', u'Ł': 'L', u'Ñ': 'N', u'Ń': 'N', u'Ň': 'N', u'Ņ': 'N', u'Ŋ': 'N', u'Ò': 'O', u'Ó': 'O', u'Ô': 'O', u'Õ': 'O', u'Ö': 'Oe', u'Ø': 'O', u'Ō': 'O', u'Ő': 'O', u'Ŏ': 'O', u'Œ': 'OE', u'Ŕ': 'R', u'Ř': 'R', u'Ŗ': 'R', u'Ś': 'S', u'Ş': 'S', u'Ŝ': 'S', u'Ș': 'S', u'Š': 'S', u'Ť': 'T', u'Ţ': 'T', u'Ŧ': 'T', u'Ț': 'T', u'Ù': 'U', u'Ú': 'U', u'Û': 'U', u'Ü': 'Ue', u'Ū': 'U', u'Ů': 'U', u'Ű': 'U', u'Ŭ': 'U', u'Ũ': 'U', u'Ų': 'U', u'Ŵ': 'W', u'Ŷ': 'Y', u'Ÿ': 'Y', u'Ý': 'Y', u'Ź': 'Z', u'Ż': 'Z', u'Ž': 'Z', u'à': 'a', u'á': 'a', u'â': 'a', u'ã': 'a', u'ä': 'ae', u'ā': 'a', u'ą': 'a', u'ă': 'a', u'å': 'a', u'æ': 'ae', u'ç': 'c', u'ć': 'c', u'č': 'c', u'ĉ': 'c', u'ċ': 'c', u'ď': 'd', u'đ': 'd', u'è': 'e', u'é': 'e', u'ê': 'e', u'ë': 'e', u'ē': 'e', u'ę': 'e', u'ě': 'e', u'ĕ': 'e', u'ė': 'e', u'ƒ': 'f', u'ĝ': 'g', u'ğ': 'g', u'ġ': 'g', u'ģ': 'g', u'ĥ': 'h', u'ħ': 'h', u'ì': 'i', u'í': 'i', u'î': 'i', u'ï': 'i', u'ī': 'i', u'ĩ': 'i', u'ĭ': 'i', u'į': 'i', u'ı': 'i', u'ij': 'ij', u'ĵ': 'j', u'ķ': 'k', u'ĸ': 'k', u'ł': 'l', u'ľ': 'l', u'ĺ': 'l', u'ļ': 'l', u'ŀ': 'l', u'ñ': 'n', u'ń': 'n', u'ň': 'n', u'ņ': 'n', u'ʼn': 'n', u'ŋ': 'n', u'ò': 'o', u'ó': 'o', u'ô': 'o', u'õ': 'o', u'ö': 'oe', u'ø': 'o', u'ō': 'o', u'ő': 'o', u'ŏ': 'o', u'œ': 'oe', u'ŕ': 'r', u'ř': 'r', u'ŗ': 'r', u'ś': 's', u'š': 's', u'ť': 't', u'ù': 'u', u'ú': 'u', u'û': 'u', u'ü': 'ue', u'ū': 'u', u'ů': 'u', u'ű': 'u', u'ŭ': 'u', u'ũ': 'u', u'ų': 'u', u'ŵ': 'w', u'ÿ': 'y', u'ý': 'y', u'ŷ': 'y', u'ż': 'z', u'ź': 'z', u'ž': 'z', u'ß': 'ss', u'ſ': 'ss', u'Α': 'A', u'Ά': 'A', u'Ἀ': 'A', u'Ἁ': 'A', u'Ἂ': 'A', u'Ἃ': 'A', u'Ἄ': 'A', u'Ἅ': 'A', u'Ἆ': 'A', u'Ἇ': 'A', u'ᾈ': 'A', u'ᾉ': 'A', u'ᾊ': 'A', u'ᾋ': 'A', u'ᾌ': 'A', u'ᾍ': 'A', u'ᾎ': 'A', u'ᾏ': 'A', u'Ᾰ': 'A', u'Ᾱ': 'A', u'Ὰ': 'A', u'Ά': 'A', u'ᾼ': 'A', u'Β': 'B', u'Γ': 'G', u'Δ': 'D', u'Ε': 'E', u'Έ': 'E', u'Ἐ': 'E', u'Ἑ': 'E', u'Ἒ': 'E', u'Ἓ': 'E', u'Ἔ': 'E', u'Ἕ': 'E', u'Έ': 'E', u'Ὲ': 'E', u'Ζ': 'Z', u'Η': 'I', u'Ή': 'I', u'Ἠ': 'I', u'Ἡ': 'I', u'Ἢ': 'I', u'Ἣ': 'I', u'Ἤ': 'I', u'Ἥ': 'I', u'Ἦ': 'I', u'Ἧ': 'I', u'ᾘ': 'I', u'ᾙ': 'I', u'ᾚ': 'I', u'ᾛ': 'I', u'ᾜ': 'I', u'ᾝ': 'I', u'ᾞ': 'I', u'ᾟ': 'I', u'Ὴ': 'I', u'Ή': 'I', u'ῌ': 'I', u'Θ': 'TH', u'Ι': 'I', u'Ί': 'I', u'Ϊ': 'I', u'Ἰ': 'I', u'Ἱ': 'I', u'Ἲ': 'I', u'Ἳ': 'I', u'Ἴ': 'I', u'Ἵ': 'I', u'Ἶ': 'I', u'Ἷ': 'I', u'Ῐ': 'I', u'Ῑ': 'I', u'Ὶ': 'I', u'Ί': 'I', u'Κ': 'K', u'Λ': 'L', u'Μ': 'M', u'Ν': 'N', u'Ξ': 'KS', u'Ο': 'O', u'Ό': 'O', u'Ὀ': 'O', u'Ὁ': 'O', u'Ὂ': 'O', u'Ὃ': 'O', u'Ὄ': 'O', u'Ὅ': 'O', u'Ὸ': 'O', u'Ό': 'O', u'Π': 'P', u'Ρ': 'R', u'Ῥ': 'R', u'Σ': 'S', u'Τ': 'T', u'Υ': 'Y', u'Ύ': 'Y', u'Ϋ': 'Y', u'Ὑ': 'Y', u'Ὓ': 'Y', u'Ὕ': 'Y', u'Ὗ': 'Y', u'Ῠ': 'Y', u'Ῡ': 'Y', u'Ὺ': 'Y', u'Ύ': 'Y', u'Φ': 'F', u'Χ': 'X', u'Ψ': 'PS', u'Ω': 'O', u'Ώ': 'O', u'Ὠ': 'O', u'Ὡ': 'O', u'Ὢ': 'O', u'Ὣ': 'O', u'Ὤ': 'O', u'Ὥ': 'O', u'Ὦ': 'O', u'Ὧ': 'O', u'ᾨ': 'O', u'ᾩ': 'O', u'ᾪ': 'O', u'ᾫ': 'O', u'ᾬ': 'O', u'ᾭ': 'O', u'ᾮ': 'O', u'ᾯ': 'O', u'Ὼ': 'O', u'Ώ': 'O', u'ῼ': 'O', u'α': 'a', u'ά': 'a', u'ἀ': 'a', u'ἁ': 'a', u'ἂ': 'a', u'ἃ': 'a', u'ἄ': 'a', u'ἅ': 'a', u'ἆ': 'a', u'ἇ': 'a', u'ᾀ': 'a', u'ᾁ': 'a', u'ᾂ': 'a', u'ᾃ': 'a', u'ᾄ': 'a', u'ᾅ': 'a', u'ᾆ': 'a', u'ᾇ': 'a', u'ὰ': 'a', u'ά': 'a', u'ᾰ': 'a', u'ᾱ': 'a', u'ᾲ': 'a', u'ᾳ': 'a', u'ᾴ': 'a', u'ᾶ': 'a', u'ᾷ': 'a', u'β': 'b', u'γ': 'g', u'δ': 'd', u'ε': 'e', u'έ': 'e', u'ἐ': 'e', u'ἑ': 'e', u'ἒ': 'e', u'ἓ': 'e', u'ἔ': 'e', u'ἕ': 'e', u'ὲ': 'e', u'έ': 'e', u'ζ': 'z', u'η': 'i', u'ή': 'i', u'ἠ': 'i', u'ἡ': 'i', u'ἢ': 'i', u'ἣ': 'i', u'ἤ': 'i', u'ἥ': 'i', u'ἦ': 'i', u'ἧ': 'i', u'ᾐ': 'i', u'ᾑ': 'i', u'ᾒ': 'i', u'ᾓ': 'i', u'ᾔ': 'i', u'ᾕ': 'i', u'ᾖ': 'i', u'ᾗ': 'i', u'ὴ': 'i', u'ή': 'i', u'ῂ': 'i', u'ῃ': 'i', u'ῄ': 'i', u'ῆ': 'i', u'ῇ': 'i', u'θ': 'th', u'ι': 'i', u'ί': 'i', u'ϊ': 'i', u'ΐ': 'i', u'ἰ': 'i', u'ἱ': 'i', u'ἲ': 'i', u'ἳ': 'i', u'ἴ': 'i', u'ἵ': 'i', u'ἶ': 'i', u'ἷ': 'i', u'ὶ': 'i', u'ί': 'i', u'ῐ': 'i', u'ῑ': 'i', u'ῒ': 'i', u'ΐ': 'i', u'ῖ': 'i', u'ῗ': 'i', u'κ': 'k', u'λ': 'l', u'μ': 'm', u'ν': 'n', u'ξ': 'ks', u'ο': 'o', u'ό': 'o', u'ὀ': 'o', u'ὁ': 'o', u'ὂ': 'o', u'ὃ': 'o', u'ὄ': 'o', u'ὅ': 'o', u'ὸ': 'o', u'ό': 'o', u'π': 'p', u'ρ': 'r', u'ῤ': 'r', u'ῥ': 'r', u'σ': 's', u'ς': 's', u'τ': 't', u'υ': 'y', u'ύ': 'y', u'ϋ': 'y', u'ΰ': 'y', u'ὐ': 'y', u'ὑ': 'y', u'ὒ': 'y', u'ὓ': 'y', u'ὔ': 'y', u'ὕ': 'y', u'ὖ': 'y', u'ὗ': 'y', u'ὺ': 'y', u'ύ': 'y', u'ῠ': 'y', u'ῡ': 'y', u'ῢ': 'y', u'ΰ': 'y', u'ῦ': 'y', u'ῧ': 'y', u'φ': 'f', u'χ': 'x', u'ψ': 'ps', u'ω': 'o', u'ώ': 'o', u'ὠ': 'o', u'ὡ': 'o', u'ὢ': 'o', u'ὣ': 'o', u'ὤ': 'o', u'ὥ': 'o', u'ὦ': 'o', u'ὧ': 'o', u'ᾠ': 'o', u'ᾡ': 'o', u'ᾢ': 'o', u'ᾣ': 'o', u'ᾤ': 'o', u'ᾥ': 'o', u'ᾦ': 'o', u'ᾧ': 'o', u'ὼ': 'o', u'ώ': 'o', u'ῲ': 'o', u'ῳ': 'o', u'ῴ': 'o', u'ῶ': 'o', u'ῷ': 'o', u'¨': '', u'΅': '', u'᾿': '', u'῾': '', u'῍': '', u'῝': '', u'῎': '', u'῞': '', u'῏': '', u'῟': '', u'῀': '', u'῁': '', u'΄': '', u'΅': '', u'`': '', u'῭': '', u'ͺ': '', u'᾽': '', u'А': 'A', u'Б': 'B', u'В': 'V', u'Г': 'G', u'Д': 'D', u'Е': 'E', u'Ё': 'YO', u'Ж': 'ZH', u'З': 'Z', u'И': 'I', u'Й': 'J', u'К': 'K', u'Л': 'L', u'М': 'M', u'Н': 'N', u'О': 'O', u'П': 'P', u'Р': 'R', u'С': 'S', u'Т': 'T', u'У': 'U', u'Ф': 'F', u'Х': 'H', u'Ц': 'TS', u'Ч': 'CH', u'Ш': 'SH', u'Щ': 'SCH', u'Ы': 'YI', u'Э': 'E', u'Ю': 'YU', u'Я': 'YA', u'а': 'A', u'б': 'B', u'в': 'V', u'г': 'G', u'д': 'D', u'е': 'E', u'ё': 'YO', u'ж': 'ZH', u'з': 'Z', u'и': 'I', u'й': 'J', u'к': 'K', u'л': 'L', u'м': 'M', u'н': 'N', u'о': 'O', u'п': 'P', u'р': 'R', u'с': 'S', u'т': 'T', u'у': 'U', u'ф': 'F', u'х': 'H', u'ц': 'TS', u'ч': 'CH', u'ш': 'SH', u'щ': 'SCH', u'ы': 'YI', u'э': 'E', u'ю': 'YU', u'я': 'YA', u'Ъ': '', u'ъ': '', u'Ь': '', u'ь': '', u'ð': 'd', u'Ð': 'D', u'þ': 'th', u'Þ': 'TH',u'ა': 'a', u'ბ': 'b', u'გ': 'g', u'დ': 'd', u'ე': 'e', u'ვ': 'v', u'ზ': 'z', u'თ': 't', u'ი': 'i', u'კ': 'k', u'ლ': 'l', u'მ': 'm', u'ნ': 'n', u'ო': 'o', u'პ': 'p', u'ჟ': 'zh', u'რ': 'r', u'ს': 's', u'ტ': 't', u'უ': 'u', u'ფ': 'p', u'ქ': 'k', u'ღ': 'gh', u'ყ': 'q', u'შ': 'sh', u'ჩ': 'ch', u'ც': 'ts', u'ძ': 'dz', u'წ': 'ts', u'ჭ': 'ch', u'ხ': 'kh', u'ჯ': 'j', u'ჰ': 'h' } def replace_char(m): char = m.group() if char_map.has_key(char): return char_map[char] else: return char _punct_re = re.compile(r'[\t !"#$%&\'()*\-/<=>?@\[\\\]^_`{|},.]+') def _slugify(text, delim=u'-'): """Generates an ASCII-only slug.""" result = [] for word in _punct_re.split(text.lower()): word = word.encode('utf-8') if word: result.append(word) slugified = delim.join([i.decode('utf-8') for i in result]) return re.sub('[^a-zA-Z0-9\\s\\-]{1}', replace_char, slugified).lower() ## end of http://code.activestate.com/recipes/577257/ }}} # From http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52549 def _curry(*args, **kwargs): function, args = args[0], args[1:] def result(*rest, **kwrest): combined = kwargs.copy() combined.update(kwrest) return function(*args + rest, **combined) return result # Recipe: regex_from_encoded_pattern (1.0) def _regex_from_encoded_pattern(s): """'foo' -> re.compile(re.escape('foo')) '/foo/' -> re.compile('foo') '/foo/i' -> re.compile('foo', re.I) """ if s.startswith('/') and s.rfind('/') != 0: # Parse it: /PATTERN/FLAGS idx = s.rfind('/') pattern, flags_str = s[1:idx], s[idx+1:] flag_from_char = { "i": re.IGNORECASE, "l": re.LOCALE, "s": re.DOTALL, "m": re.MULTILINE, "u": re.UNICODE, } flags = 0 for char in flags_str: try: flags |= flag_from_char[char] except KeyError: raise ValueError("unsupported regex flag: '%s' in '%s' " "(must be one of '%s')" % (char, s, ''.join(list(flag_from_char.keys())))) return re.compile(s[1:idx], flags) else: # not an encoded regex return re.compile(re.escape(s)) # Recipe: dedent (0.1.2) def _dedentlines(lines, tabsize=8, skip_first_line=False): """_dedentlines(lines, tabsize=8, skip_first_line=False) -> dedented lines "lines" is a list of lines to dedent. "tabsize" is the tab width to use for indent width calculations. "skip_first_line" is a boolean indicating if the first line should be skipped for calculating the indent width and for dedenting. This is sometimes useful for docstrings and similar. Same as dedent() except operates on a sequence of lines. Note: the lines list is modified **in-place**. """ DEBUG = False if DEBUG: print("dedent: dedent(..., tabsize=%d, skip_first_line=%r)"\ % (tabsize, skip_first_line)) indents = [] margin = None for i, line in enumerate(lines): if i == 0 and skip_first_line: continue indent = 0 for ch in line: if ch == ' ': indent += 1 elif ch == '\t': indent += tabsize - (indent % tabsize) elif ch in '\r\n': continue # skip all-whitespace lines else: break else: continue # skip all-whitespace lines if DEBUG: print("dedent: indent=%d: %r" % (indent, line)) if margin is None: margin = indent else: margin = min(margin, indent) if DEBUG: print("dedent: margin=%r" % margin) if margin is not None and margin > 0: for i, line in enumerate(lines): if i == 0 and skip_first_line: continue removed = 0 for j, ch in enumerate(line): if ch == ' ': removed += 1 elif ch == '\t': removed += tabsize - (removed % tabsize) elif ch in '\r\n': if DEBUG: print("dedent: %r: EOL -> strip up to EOL" % line) lines[i] = lines[i][j:] break else: raise ValueError("unexpected non-whitespace char %r in " "line %r while removing %d-space margin" % (ch, line, margin)) if DEBUG: print("dedent: %r: %r -> removed %d/%d"\ % (line, ch, removed, margin)) if removed == margin: lines[i] = lines[i][j+1:] break elif removed > margin: lines[i] = ' '*(removed-margin) + lines[i][j+1:] break else: if removed: lines[i] = lines[i][removed:] return lines def _dedent(text, tabsize=8, skip_first_line=False): """_dedent(text, tabsize=8, skip_first_line=False) -> dedented text "text" is the text to dedent. "tabsize" is the tab width to use for indent width calculations. "skip_first_line" is a boolean indicating if the first line should be skipped for calculating the indent width and for dedenting. This is sometimes useful for docstrings and similar. textwrap.dedent(s), but don't expand tabs to spaces """ lines = text.splitlines(1) _dedentlines(lines, tabsize=tabsize, skip_first_line=skip_first_line) return ''.join(lines) class _memoized: """Decorator that caches a function's return value each time it is called. If called later with the same arguments, the cached value is returned, and not re-evaluated. http://wiki.python.org/moin/PythonDecoratorLibrary """ def __init__(self, func): self.func = func self.cache = {} def __call__(self, *args): try: return self.cache[args] except KeyError: self.cache[args] = value = self.func(*args) return value except TypeError: # uncachable -- for instance, passing a list as an argument. # Better to not cache than to blow up entirely. return self.func(*args) def __repr__(self): """Return the function's docstring.""" return self.func.__doc__ def _xml_oneliner_re_from_tab_width(tab_width): """Standalone XML processing instruction regex.""" return re.compile(r""" (?: (?<=\n\n) # Starting after a blank line | # or \A\n? # the beginning of the doc ) ( # save in $1 [ ]{0,%d} (?: <\?\w+\b\s+.*?\?> # XML processing instruction | <\w+:\w+\b\s+.*?/> # namespaced single tag ) [ \t]* (?=\n{2,}|\Z) # followed by a blank line or end of document ) """ % (tab_width - 1), re.X) _xml_oneliner_re_from_tab_width = _memoized(_xml_oneliner_re_from_tab_width) def _hr_tag_re_from_tab_width(tab_width): return re.compile(r""" (?: (?<=\n\n) # Starting after a blank line | # or \A\n? # the beginning of the doc ) ( # save in \1 [ ]{0,%d} <(hr) # start tag = \2 \b # word break ([^<>])*? # /?> # the matching end tag [ \t]* (?=\n{2,}|\Z) # followed by a blank line or end of document ) """ % (tab_width - 1), re.X) _hr_tag_re_from_tab_width = _memoized(_hr_tag_re_from_tab_width) def _xml_escape_attr(attr, skip_single_quote=True): """Escape the given string for use in an HTML/XML tag attribute. By default this doesn't bother with escaping `'` to `'`, presuming that the tag attribute is surrounded by double quotes. """ escaped = (attr .replace('&', '&') .replace('"', '"') .replace('<', '<') .replace('>', '>')) if not skip_single_quote: escaped = escaped.replace("'", "'") return escaped def _xml_encode_email_char_at_random(ch): r = random() # Roughly 10% raw, 45% hex, 45% dec. # '@' *must* be encoded. I [John Gruber] insist. # Issue 26: '_' must be encoded. if r > 0.9 and ch not in "@_": return ch elif r < 0.45: # The [1:] is to drop leading '0': 0x63 -> x63 return '&#%s;' % hex(ord(ch))[1:] else: return '&#%s;' % ord(ch) #---- mainline class _NoReflowFormatter(optparse.IndentedHelpFormatter): """An optparse formatter that does NOT reflow the description.""" def format_description(self, description): return description or "" def _test(): import doctest doctest.testmod() def main(argv=None): if argv is None: argv = sys.argv if not logging.root.handlers: logging.basicConfig() usage = "usage: %prog [PATHS...]" version = "%prog "+__version__ parser = optparse.OptionParser(prog="markdown2", usage=usage, version=version, description=cmdln_desc, formatter=_NoReflowFormatter()) parser.add_option("-v", "--verbose", dest="log_level", action="store_const", const=logging.DEBUG, help="more verbose output") parser.add_option("--encoding", help="specify encoding of text content") parser.add_option("--html4tags", action="store_true", default=False, help="use HTML 4 style for empty element tags") parser.add_option("-s", "--safe", metavar="MODE", dest="safe_mode", help="sanitize literal HTML: 'escape' escapes " "HTML meta chars, 'replace' replaces with an " "[HTML_REMOVED] note") parser.add_option("-x", "--extras", action="append", help="Turn on specific extra features (not part of " "the core Markdown spec). See above.") parser.add_option("--use-file-vars", help="Look for and use Emacs-style 'markdown-extras' " "file var to turn on extras. See " "<https://github.com/trentm/python-markdown2/wiki/Extras>") parser.add_option("--link-patterns-file", help="path to a link pattern file") parser.add_option("--self-test", action="store_true", help="run internal self-tests (some doctests)") parser.add_option("--compare", action="store_true", help="run against Markdown.pl as well (for testing)") parser.set_defaults(log_level=logging.INFO, compare=False, encoding="utf-8", safe_mode=None, use_file_vars=False) opts, paths = parser.parse_args() log.setLevel(opts.log_level) if opts.self_test: return _test() if opts.extras: extras = {} for s in opts.extras: splitter = re.compile("[,;: ]+") for e in splitter.split(s): if '=' in e: ename, earg = e.split('=', 1) try: earg = int(earg) except ValueError: pass else: ename, earg = e, None extras[ename] = earg else: extras = None if opts.link_patterns_file: link_patterns = [] f = open(opts.link_patterns_file) try: for i, line in enumerate(f.readlines()): if not line.strip(): continue if line.lstrip().startswith("#"): continue try: pat, href = line.rstrip().rsplit(None, 1) except ValueError: raise MarkdownError("%s:%d: invalid link pattern line: %r" % (opts.link_patterns_file, i+1, line)) link_patterns.append( (_regex_from_encoded_pattern(pat), href)) finally: f.close() else: link_patterns = None from os.path import join, dirname, abspath, exists markdown_pl = join(dirname(dirname(abspath(__file__))), "test", "Markdown.pl") if not paths: paths = ['-'] for path in paths: if path == '-': text = sys.stdin.read() else: fp = codecs.open(path, 'r', opts.encoding) text = fp.read() fp.close() if opts.compare: from subprocess import Popen, PIPE print("==== Markdown.pl ====") p = Popen('perl %s' % markdown_pl, shell=True, stdin=PIPE, stdout=PIPE, close_fds=True) p.stdin.write(text.encode('utf-8')) p.stdin.close() perl_html = p.stdout.read().decode('utf-8') if py3: sys.stdout.write(perl_html) else: sys.stdout.write(perl_html.encode( sys.stdout.encoding or "utf-8", 'xmlcharrefreplace')) print("==== markdown2.py ====") html = markdown(text, html4tags=opts.html4tags, safe_mode=opts.safe_mode, extras=extras, link_patterns=link_patterns, use_file_vars=opts.use_file_vars) if py3: sys.stdout.write(html) else: sys.stdout.write(html.encode( sys.stdout.encoding or "utf-8", 'xmlcharrefreplace')) if extras and "toc" in extras: log.debug("toc_html: " + html.toc_html.encode(sys.stdout.encoding or "utf-8", 'xmlcharrefreplace')) if opts.compare: test_dir = join(dirname(dirname(abspath(__file__))), "test") if exists(join(test_dir, "test_markdown2.py")): sys.path.insert(0, test_dir) from test_markdown2 import norm_html_from_html norm_html = norm_html_from_html(html) norm_perl_html = norm_html_from_html(perl_html) else: norm_html = html norm_perl_html = perl_html print("==== match? %r ====" % (norm_perl_html == norm_html)) template_html = u'''<!DOCTYPE html> <!-- Backdoc is a tool for backbone-like documentation generation. Backdoc main goal is to help to generate one page documentation from one markdown source file. https://github.com/chibisov/backdoc --> <html lang="ru"> <head> <meta charset="UTF-8"/> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"> <meta name="HandheldFriendly" content="true" /> <title><!-- title -->
''' def force_text(text): if isinstance(text, unicode): return text else: return text.decode('utf-8') class BackDoc: def __init__(self, markdown_converter, template_html, stdin, stdout): self.markdown_converter = markdown_converter self.template_html = force_text(template_html) self.stdin = stdin self.stdout = stdout self.parser = self.get_parser() def run(self, argv): kwargs = self.get_kwargs(argv) self.stdout.write(self.get_result_html(**kwargs)) def get_kwargs(self, argv): parsed = dict(self.parser.parse_args(argv)._get_kwargs()) return self.prepare_kwargs_from_parsed_data(parsed) def prepare_kwargs_from_parsed_data(self, parsed): kwargs = {} kwargs['title'] = force_text(parsed.get('title') or 'Documentation') if parsed.get('source'): kwargs['markdown_src'] = open(parsed['source'], 'r').read() else: kwargs['markdown_src'] = self.stdin.read() kwargs['markdown_src'] = force_text(kwargs['markdown_src'] or '') return kwargs def get_result_html(self, title, markdown_src): response = self.get_converted_to_html_response(markdown_src) return ( self.template_html.replace('', title) .replace('', response.toc_html and force_text(response.toc_html) or '') .replace('', force_text(response)) ) def get_converted_to_html_response(self, markdown_src): return self.markdown_converter.convert(markdown_src) def get_parser(self): parser = argparse.ArgumentParser() parser.add_argument( '-t', '--title', help='Documentation title header', required=False, ) parser.add_argument( '-s', '--source', help='Markdown source file path', required=False, ) return parser if __name__ == '__main__': BackDoc( markdown_converter=Markdown(extras=['toc']), template_html=template_html, stdin=sys.stdin, stdout=sys.stdout ).run(argv=sys.argv[1:]) drf-extensions-0.6.0/docs/index.html000066400000000000000000000000001361353156700174120ustar00rootroot00000000000000drf-extensions-0.6.0/docs/index.md000066400000000000000000002652011361353156700170660ustar00rootroot00000000000000### DRF-extensions DRF-extensions is a collection of custom extensions for [Django REST Framework](https://github.com/tomchristie/django-rest-framework). Source repository is available at [https://github.com/chibisov/drf-extensions](https://github.com/chibisov/drf-extensions). ### Viewsets Extensions for [viewsets](http://django-rest-framework.org/api-guide/viewsets.html). #### DetailSerializerMixin This mixin lets add custom serializer for detail view. Just add mixin and specify `serializer_detail_class` attribute: from django.contrib.auth.models import User from myapps.serializers import UserSerializer, UserDetailSerializer from rest_framework_extensions.mixins import DetailSerializerMixin class UserViewSet(DetailSerializerMixin, viewsets.ReadOnlyModelViewSet): serializer_class = UserSerializer serializer_detail_class = UserDetailSerializer queryset = User.objects.all() Sometimes you need to set custom QuerySet for detail view. For example, in detail view you want to show user groups and permissions for these groups. You can make it by specifying `queryset_detail` attribute: from django.contrib.auth.models import User from myapps.serializers import UserSerializer, UserDetailSerializer from rest_framework_extensions.mixins import DetailSerializerMixin class UserViewSet(DetailSerializerMixin, viewsets.ReadOnlyModelViewSet): serializer_class = UserSerializer serializer_detail_class = UserDetailSerializer queryset = User.objects.all() queryset_detail = queryset.prefetch_related('groups__permissions') If you use `DetailSerializerMixin` and don't specify `serializer_detail_class` attribute, then `serializer_class` will be used. If you use `DetailSerializerMixin` and don't specify `queryset_detail` attribute, then `queryset` will be used. #### PaginateByMaxMixin *New in DRF-extensions 0.2.2* This mixin allows to paginate results by [max\_paginate\_by](http://www.django-rest-framework.org/api-guide/pagination#pagination-in-the-generic-views) value. This approach is useful when clients want to take as much paginated data as possible, but don't want to bother about backend limitations. from myapps.serializers import UserSerializer from rest_framework_extensions.mixins import PaginateByMaxMixin class UserViewSet(PaginateByMaxMixin, viewsets.ReadOnlyModelViewSet): max_paginate_by = 100 serializer_class = UserSerializer And now you can send requests with `?page_size=max` argument: # Request GET /users/?page_size=max HTTP/1.1 Accept: application/json # Response HTTP/1.1 200 OK Content-Type: application/json; charset=UTF-8 { count: 1000, next: "https://localhost:8000/v1/users/?page=2&page_size=max", previous: null, results: [ ...100 items... ] } This mixin could be used only with Django Rest Framework >= 2.3.8, because [max\_paginate\_by](http://www.django-rest-framework.org/topics/release-notes#238) was introduced in 2.3.8 version. #### Cache/ETAG mixins The etag functionality is pending an overhaul has been temporarily removed since 0.4.0. ReadOnlyCacheResponseAndETAGMixin and CacheResponseAndETAGMixin are no longer available to use. See discussion in [Issue #177](https://github.com/chibisov/drf-extensions/issues/177) ### Routers Extensions for [routers](http://django-rest-framework.org/api-guide/routers.html). You will need to use custom `ExtendedDefaultRouter` or `ExtendedSimpleRouter` for routing if you want to take advantages of described extensions. For example you have standard implementation: from rest_framework.routers import DefaultRouter router = DefaultRouter() You should replace `DefaultRouter` with `ExtendedDefaultRouter`: from rest_framework_extensions.routers import ( ExtendedDefaultRouter as DefaultRouter ) router = DefaultRouter() Or `SimpleRouter` with `ExtendedSimpleRouter`: from rest_framework_extensions.routers import ( ExtendedSimpleRouter as SimpleRouter ) router = SimpleRouter() #### Pluggable router mixins *New in DRF-extensions 0.2.4* Every feature in extended routers has it's own mixin. That means that you can use the only features you need in your custom routers. `ExtendedRouterMixin` has all set of drf-extensions features. For example you can use it with third-party routes: from rest_framework_extensions.routers import ExtendedRouterMixin from third_party_app.routers import SomeRouter class ExtendedSomeRouter(ExtendedRouterMixin, SomeRouter): pass ### Nested routes *New in DRF-extensions 0.2.4* Nested routes allows you create nested resources with [viewsets](http://www.django-rest-framework.org/api-guide/viewsets.html). For example: from rest_framework_extensions.routers import ExtendedSimpleRouter from yourapp.views import ( UserViewSet, GroupViewSet, PermissionViewSet, ) router = ExtendedSimpleRouter() ( router.register(r'users', UserViewSet, basename='user') .register(r'groups', GroupViewSet, basename='users-group', parents_query_lookups=['user_groups']) .register(r'permissions', PermissionViewSet, basename='users-groups-permission', parents_query_lookups=['group__user', 'group']) ) urlpatterns = router.urls There is one requirement for viewsets which used in nested routers. They should add mixin `NestedViewSetMixin`. That mixin adds automatic filtering by parent lookups: # yourapp.views from rest_framework_extensions.mixins import NestedViewSetMixin class UserViewSet(NestedViewSetMixin, ModelViewSet): model = UserModel class GroupViewSet(NestedViewSetMixin, ModelViewSet): model = GroupModel class PermissionViewSet(NestedViewSetMixin, ModelViewSet): model = PermissionModel With such kind of router we have next resources: * `/users/` - list of all users. Resolve name is **user-list** * `/users//` - user detail. Resolve name is **user-detail** * `/users//groups/` - list of groups for exact user. Resolve name is **users-group-list** * `/users//groups//` - user group detail. If user doesn't have group then resource will be not found. Resolve name is **users-group-detail** * `/users//groups//permissions/` - list of permissions for user group. Resolve name is **users-groups-permission-list** * `/users//groups//permissions//` - user group permission detail. If user doesn't have group or group doesn't have permission then resource will be not found. Resolve name is **users-groups-permission-detail** Every resource is automatically filtered by parent lookups. # Request GET /users/1/groups/2/permissions/ HTTP/1.1 Accept: application/json # Response HTTP/1.1 200 OK Content-Type: application/json; charset=UTF-8 [ { id: 3, name: "read" }, { id: 4, name: "update" }, { id: 5, name: "delete" } ] For request above permissions will be filtered by user with pk `1` and group with pk `2`: Permission.objects.filter(group__user=1, group=2) Example with registering more then one nested resource in one depth: permissions_routes = router.register( r'permissions', PermissionViewSet, basename='permission' ) permissions_routes.register( r'groups', GroupViewSet, basename='permissions-group', parents_query_lookups=['permissions'] ) permissions_routes.register( r'users', UserViewSet, basename='permissions-user', parents_query_lookups=['groups__permissions'] ) With such kind of router we have next resources: * `/permissions/` - list of all permissions. Resolve name is **permission-list** * `/permissions//` - permission detail. Resolve name is **permission-detail** * `/permissions//groups/` - list of groups for exact permission. Resolve name is **permissions-group-list** * `/permissions//groups//` - permission group detail. If group doesn't have permission then resource will be not found. Resolve name is **permissions-group-detail** * `/permissions//users/` - list of users for exact permission. Resolve name is **permissions-user-list** * `/permissions//user//` - permission user detail. If user doesn't have permission then resource will be not found. Resolve name is **permissions-user-detail** #### Nested router mixin You can use `rest_framework_extensions.routers.NestedRouterMixin` for adding nesting feature into your routers: from rest_framework_extensions.routers import NestedRouterMixin from rest_framework.routers import SimpleRouter class SimpleRouterWithNesting(NestedRouterMixin, SimpleRouter): pass #### Usage with generic relations If you want to use nested router for [generic relation](https://docs.djangoproject.com/en/dev/ref/contrib/contenttypes/#generic-relations) fields, you should explicitly filter `QuerySet` by content type. For example if you have such kind of models: class Task(models.Model): title = models.CharField(max_length=30) class Book(models.Model): title = models.CharField(max_length=30) class Comment(models.Model): content_type = models.ForeignKey(ContentType) object_id = models.PositiveIntegerField() content_object = generic.GenericForeignKey() text = models.CharField(max_length=30) Lets create viewsets for that models: class TaskViewSet(NestedViewSetMixin, ModelViewSet): model = TaskModel class BookViewSet(NestedViewSetMixin, ModelViewSet): model = BookModel class CommentViewSet(NestedViewSetMixin, ModelViewSet): queryset = CommentModel.objects.all() And router like this: router = ExtendedSimpleRouter() # tasks route ( router.register(r'tasks', TaskViewSet) .register(r'comments', CommentViewSet, 'tasks-comment', parents_query_lookups=['object_id']) ) # books route ( router.register(r'books', BookViewSet) .register(r'comments', CommentViewSet, 'books-comment', parents_query_lookups=['object_id']) ) As you can see we've added to `parents_query_lookups` only one `object_id` value. But when you make requests to `comments` endpoint for both tasks and books routes there is no context for current content type. # Request GET /tasks/123/comments/ HTTP/1.1 Accept: application/json # Response HTTP/1.1 200 OK Content-Type: application/json; charset=UTF-8 [ { id: 1, content_type: 1, object_id: 123, text: "Good task!" }, { id: 2, content_type: 2, // oops. Wrong content type (for book) object_id: 123, // task and book has the same id text: "Good book!" }, ] For such kind of cases you should explicitly filter `QuerySets` of nested viewsets by content type: from django.contrib.contenttypes.models import ContentType class CommentViewSet(NestedViewSetMixin, ModelViewSet): queryset = CommentModel.objects.all() class TaskCommentViewSet(CommentViewSet): def get_queryset(self): return super(TaskCommentViewSet, self).get_queryset().filter( content_type=ContentType.objects.get_for_model(TaskModel) ) class BookCommentViewSet(CommentViewSet): def get_queryset(self): return super(BookCommentViewSet, self).get_queryset().filter( content_type=ContentType.objects.get_for_model(BookModel) ) Lets use new viewsets in router: router = ExtendedSimpleRouter() # tasks route ( router.register(r'tasks', TaskViewSet) .register(r'comments', TaskCommentViewSet, 'tasks-comment', parents_query_lookups=['object_id']) ) # books route ( router.register(r'books', BookViewSet) .register(r'comments', BookCommentViewSet, 'books-comment', parents_query_lookups=['object_id']) ) ### Serializers Extensions for [serializers](http://www.django-rest-framework.org/api-guide/serializers) functionality. #### PartialUpdateSerializerMixin *New in DRF-extensions 0.2.3* By default every saving of [ModelSerializer](http://www.django-rest-framework.org/api-guide/serializers#modelserializer) saves the whole object. Even partial update just patches model instance. For example: from myapps.models import City from myapps.serializers import CitySerializer moscow = City.objects.get(pk=10) city_serializer = CitySerializer( instance=moscow, data={'country': 'USA'}, partial=True ) if city_serializer.is_valid(): city_serializer.save() # equivalent to moscow.country = 'USA' moscow.save() SQL representation for previous example will be: UPDATE city SET name='Moscow', country='USA' WHERE id=1; Django's `save` method has keyword argument [update_fields](https://docs.djangoproject.com/en/dev/ref/models/instances/#specifying-which-fields-to-save). Only the fields named in that list will be updated: moscow.country = 'USA' moscow.save(update_fields=['country']) SQL representation for example with `update_fields` usage will be: UPDATE city SET country='USA' WHERE id=1; To use `update_fields` for every partial update you should mixin `PartialUpdateSerializerMixin` to your serializer: from rest_framework_extensions.serializers import ( PartialUpdateSerializerMixin ) class CitySerializer(PartialUpdateSerializerMixin, serializers.ModelSerializer): class Meta: model = City ### Fields Set of serializer fields that extends [default fields](http://www.django-rest-framework.org/api-guide/fields) functionality. #### ResourceUriField Represents a hyperlinking uri that points to the detail view for that object. from rest_framework_extensions.fields import ResourceUriField class CitySerializer(serializers.ModelSerializer): resource_uri = ResourceUriField(view_name='city-detail') class Meta: model = City Request example: # Request GET /cities/268/ HTTP/1.1 Accept: application/json # Response HTTP/1.1 200 OK Content-Type: application/json; charset=UTF-8 { id: 268, resource_uri: "http://localhost:8000/v1/cities/268/", name: "Serpuhov" } ### Permissions Extensions for [permissions](http://www.django-rest-framework.org/api-guide/permissions.html). #### Object permissions *New in DRF-extensions 0.2.2* Django Rest Framework allows you to use [DjangoObjectPermissions](http://www.django-rest-framework.org/api-guide/permissions#djangoobjectpermissions) out of the box. But it has one limitation - if user has no permissions for viewing resource he will get `404` as response code. In most cases it's good approach because it solves security issues by default. But what if you wanted to return `401` or `403`? What if you wanted to say to user - "You need to be logged in for viewing current resource" or "You don't have permissions for viewing current resource"? `ExtenedDjangoObjectPermissions` will help you to be more flexible. By default it behaves as standard [DjangoObjectPermissions](http://www.django-rest-framework.org/api-guide/permissions#djangoobjectpermissions). For example, it is safe to replace `DjangoObjectPermissions` with extended permissions class: from rest_framework_extensions.permissions import ( ExtendedDjangoObjectPermissions as DjangoObjectPermissions ) class CommentView(viewsets.ModelViewSet): permission_classes = (DjangoObjectPermissions,) Now every request from unauthorized user will get `404` response: # Request GET /comments/1/ HTTP/1.1 Accept: application/json # Response HTTP/1.1 404 NOT FOUND Content-Type: application/json; charset=UTF-8 {"detail": "Not found"} With `ExtenedDjangoObjectPermissions` you can disable hiding forbidden for read objects by changing `hide_forbidden_for_read_objects` attribute: from rest_framework_extensions.permissions import ( ExtendedDjangoObjectPermissions ) class CommentViewObjectPermissions(ExtendedDjangoObjectPermissions): hide_forbidden_for_read_objects = False class CommentView(viewsets.ModelViewSet): permission_classes = (CommentViewObjectPermissions,) Now lets see request response for user that has no permissions for viewing `CommentView` object: # Request GET /comments/1/ HTTP/1.1 Accept: application/json # Response HTTP/1.1 403 FORBIDDEN Content-Type: application/json; charset=UTF-8 {u'detail': u'You do not have permission to perform this action.'} `ExtenedDjangoObjectPermissions` could be used only with Django Rest Framework >= 2.3.8, because [DjangoObjectPermissions](http://www.django-rest-framework.org/topics/release-notes#238) was introduced in 2.3.8 version. ### Caching To cache something is to save the result of an expensive calculation so that you don't have to perform the calculation next time. Here's some pseudocode explaining how this would work for a dynamically generated api response: given a URL, try finding that API response in the cache if the response is in the cache: return the cached response else: generate the response save the generated response in the cache (for next time) return the generated response #### Cache response DRF-extensions allows you to cache api responses with simple `@cache_response` decorator. There are two requirements for decorated method: * It should be method of class which is inherited from `rest_framework.views.APIView` * It should return `rest_framework.response.Response` instance. Usage example: from rest_framework.response import Response from rest_framework import views from rest_framework_extensions.cache.decorators import ( cache_response ) from myapp.models import City class CityView(views.APIView): @cache_response() def get(self, request, *args, **kwargs): cities = City.objects.all().values_list('name', flat=True) return Response(cities) If you request view first time you'll get it from processed SQL query. (~60ms response time): # Request GET /cities/ HTTP/1.1 Accept: application/json # Response HTTP/1.1 200 OK Content-Type: application/json; charset=UTF-8 ['Moscow', 'London', 'Paris'] Second request will hit the cache. No sql evaluation, no database query. (~30 ms response time): # Request GET /cities/ HTTP/1.1 Accept: application/json # Response HTTP/1.1 200 OK Content-Type: application/json; charset=UTF-8 ['Moscow', 'London', 'Paris'] Reduction in response time depends on calculation complexity inside your API method. Sometimes it reduces from 1 second to 10ms, sometimes you win just 10ms. *New in DRF-extensions 0.4.0* The decorator will render and discard the original DRF response in favor of Django's `HttpResponse`. This allows the cache to retain a smaller memory footprint and eliminates the need to re-render responses on each request. Furthermore it eliminates the risk for users to unknowingly cache whole Serializers and QuerySets. You can disable this behavior in your test suite by using [dummy caching](https://docs.djangoproject.com/en/stable/topics/cache/#dummy-caching-for-development) for the DRF-extensions cache (set via `DEFAULT_USE_CACHE`). #### Timeout You can specify cache timeout in seconds, providing first argument: class CityView(views.APIView): @cache_response(60 * 15) def get(self, request, *args, **kwargs): ... In the above example, the result of the `get()` view will be cached for 15 minutes. If you don't specify `timeout` argument then value from `REST_FRAMEWORK_EXTENSIONS` settings will be used. By default it's `None`, which means "cache forever". You can change this default in settings: REST_FRAMEWORK_EXTENSIONS = { 'DEFAULT_CACHE_RESPONSE_TIMEOUT': 60 * 15 } #### Usage of the specific cache *New in DRF-extensions 0.2.3* `@cache_response` can also take an optional keyword argument, `cache`, which directs the decorator to use a specific cache (from your [CACHES](https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-CACHES) setting) when caching results. By default, the `default` cache will be used, but you can specify any cache you want: class CityView(views.APIView): @cache_response(60 * 15, cache='special_cache') def get(self, request, *args, **kwargs): ... You can specify what cache to use by default in settings: REST_FRAMEWORK_EXTENSIONS = { 'DEFAULT_USE_CACHE': 'special_cache' } #### Cache key By default every cached data from `@cache_response` decorator stored by key, which calculated with [DefaultKeyConstructor](#default-key-constructor). You can change cache key by providing `key_func` argument, which must be callable: def calculate_cache_key(view_instance, view_method, request, args, kwargs): return '.'.join([ len(args), len(kwargs) ]) class CityView(views.APIView): @cache_response(60 * 15, key_func=calculate_cache_key) def get(self, request, *args, **kwargs): ... You can implement view method and use it for cache key calculation by specifying `key_func` argument as string: class CityView(views.APIView): @cache_response(60 * 15, key_func='calculate_cache_key') def get(self, request, *args, **kwargs): ... def calculate_cache_key(self, view_instance, view_method, request, args, kwargs): return '.'.join([ len(args), len(kwargs) ]) Key calculation function will be called with next parameters: * **view_instance** - view instance of decorated method * **view_method** - decorated method * **request** - decorated method request * **args** - decorated method positional arguments * **kwargs** - decorated method keyword arguments #### Default key function If `@cache_response` decorator used without key argument then default key function will be used. You can change this function in settings: REST_FRAMEWORK_EXTENSIONS = { 'DEFAULT_CACHE_KEY_FUNC': 'rest_framework_extensions.utils.default_cache_key_func' } `default_cache_key_func` uses [DefaultKeyConstructor](#default-key-constructor) as a base for key calculation. #### Caching errors *New in DRF-extensions 0.2.7* By default every response is cached, even failed. For example: class CityView(views.APIView): @cache_response() def get(self, request, *args, **kwargs): raise Exception("500 error comes from here") First request to `CityView.get` will fail with `500` status code error and next requests to this endpoint will return `500` error from cache. You can change this behaviour by turning off caching error responses: class CityView(views.APIView): @cache_response(cache_errors=False) def get(self, request, *args, **kwargs): raise Exception("500 error comes from here") You can change default behaviour by changing `DEFAULT_CACHE_ERRORS` setting: REST_FRAMEWORK_EXTENSIONS = { 'DEFAULT_CACHE_ERRORS': False } #### CacheResponseMixin It is common to cache standard [viewset](http://www.django-rest-framework.org/api-guide/viewsets) `retrieve` and `list` methods. That is why `CacheResponseMixin` exists. Just mix it into viewset implementation and those methods will use functions, defined in `REST_FRAMEWORK_EXTENSIONS` [settings](#settings): * *"DEFAULT\_OBJECT\_CACHE\_KEY\_FUNC"* for `retrieve` method * *"DEFAULT\_LIST\_CACHE\_KEY\_FUNC"* for `list` method By default those functions are using [DefaultKeyConstructor](#default-key-constructor) and extends it: * With `RetrieveSqlQueryKeyBit` for *"DEFAULT\_OBJECT\_CACHE\_KEY\_FUNC"* * With `ListSqlQueryKeyBit` and `PaginationKeyBit` for *"DEFAULT\_LIST\_CACHE\_KEY\_FUNC"* You can change those settings for custom cache key generation: REST_FRAMEWORK_EXTENSIONS = { 'DEFAULT_OBJECT_CACHE_KEY_FUNC': 'rest_framework_extensions.utils.default_object_cache_key_func', 'DEFAULT_LIST_CACHE_KEY_FUNC': 'rest_framework_extensions.utils.default_list_cache_key_func', 'DEFAULT_CACHE_RESPONSE_TIMEOUT': None, } Mixin example usage: from myapps.serializers import UserSerializer from rest_framework_extensions.cache.mixins import CacheResponseMixin class UserViewSet(CacheResponseMixin, viewsets.ModelViewSet): serializer_class = UserSerializer You can change cache key function by providing `object_cache_key_func` or `list_cache_key_func` methods in view class: class UserViewSet(CacheResponseMixin, viewsets.ModelViewSet): serializer_class = UserSerializer def object_cache_key_func(self, **kwargs): return 'some key for object' def list_cache_key_func(self, **kwargs): return 'some key for list' Of course you can use custom [key constructor](#key-constructor): from yourapp.key_constructors import ( CustomObjectKeyConstructor, CustomListKeyConstructor, ) class UserViewSet(CacheResponseMixin, viewsets.ModelViewSet): serializer_class = UserSerializer object_cache_key_func = CustomObjectKeyConstructor() list_cache_key_func = CustomListKeyConstructor() *New in DRF-extensions development* You can change cache timeout by providing `object_cache_timeout` or `list_cache_timeout` properties in view class: class UserViewSet(CacheResponseMixin, viewsets.ModelViewSet): serializer_class = UserSerializer object_cache_timeout = 3600 # one hours (in seconds) list_cache_timeout = 60 # one minute (in seconds) If you want to cache only `retrieve` method then you could use `rest_framework_extensions.cache.mixins.RetrieveCacheResponseMixin`. If you want to cache only `list` method then you could use `rest_framework_extensions.cache.mixins.ListCacheResponseMixin`. ### Key constructors As you could see from previous section cache key calculation might seem fairly simple operation. But let's see next example. We make ordinary HTTP request to cities resource: # Request GET /cities/ HTTP/1.1 Accept: application/json # Response HTTP/1.1 200 OK Content-Type: application/json; charset=UTF-8 ['Moscow', 'London', 'Paris'] By the moment all goes fine - response returned and cached. Let's make the same request requiring XML response: # Request GET /cities/ HTTP/1.1 Accept: application/xml # Response HTTP/1.1 200 OK Content-Type: application/json; charset=UTF-8 ['Moscow', 'London', 'Paris'] What is that? Oh, we forgot about format negotiations. We can add format to key bits: def calculate_cache_key(view_instance, view_method, request, args, kwargs): return '.'.join([ len(args), len(kwargs), request.accepted_renderer.format # here it is ]) # Request GET /cities/ HTTP/1.1 Accept: application/xml # Response HTTP/1.1 200 OK Content-Type: application/xml; charset=UTF-8 Moscow London Paris That's cool now - we have different responses for different formats with different cache keys. But there are many cases, where key should be different for different requests: * Response format (json, xml); * User (exact authorized user or anonymous); * Different request meta data (request.META['REMOTE_ADDR']); * Language (ru, en); * Headers; * Query params. For example, `jsonp` resources need `callback` param, which rendered in response; * Pagination. We should show different data for different pages; * Etc... Of course we can use custom `calculate_cache_key` methods and reuse them for different API methods, but we can't reuse just parts of them. For example, one method depends on user id and language, but another only on user id. How to be more DRYish? Let's see some magic: from rest_framework_extensions.key_constructor.constructors import ( KeyConstructor ) from rest_framework_extensions.key_constructor import bits from your_app.utils import get_city_by_ip class CityGetKeyConstructor(KeyConstructor): unique_method_id = bits.UniqueMethodIdKeyBit() format = bits.FormatKeyBit() language = bits.LanguageKeyBit() class CityHeadKeyConstructor(CityGetKeyConstructor): user = bits.UserKeyBit() request_meta = bits.RequestMetaKeyBit(params=['REMOTE_ADDR']) class CityView(views.APIView): @cache_response(key_func=CityGetKeyConstructor()) def get(self, request, *args, **kwargs): cities = City.objects.all().values_list('name', flat=True) return Response(cities) @cache_response(key_func=CityHeadKeyConstructor()) def head(self, request, *args, **kwargs): city = '' user = self.request.user if user.is_authenticated and user.city: city = Response(user.city.name) if not city: city = get_city_by_ip(request.META['REMOTE_ADDR']) return Response(city) Firstly, let's revise `CityView.get` method cache key calculation. It constructs from 3 bits: * **unique\_method\_id** - remember our [default key calculation](#cache-key)? Here it is. Just one of the cache key bits. `head` method has different set of bits and they can't collide with `get` method bits. But there could be another view class with the same bits. * **format** - key would be different for different formats. * **language** - key would be different for different languages. The second method `head` has the same `unique_method_id`, `format` and `language` bits, buts extends with 2 more: * **user** - key would be different for different users. As you can see in response calculation we use `request.user` instance. For different users we need different responses. * **request_meta** - key would be different for different ip addresses. As you can see in response calculation we are falling back to getting city from ip address if couldn't get it from authorized user model. All default key bits are listed in [this section](#default-key-bits). #### Default key constructor `DefaultKeyConstructor` is located in `rest_framework_extensions.key_constructor.constructors` module and constructs a key from unique *method* id, request format and request language. It has the following implementation: class DefaultKeyConstructor(KeyConstructor): unique_method_id = bits.UniqueMethodIdKeyBit() format = bits.FormatKeyBit() language = bits.LanguageKeyBit() #### How key constructor works Key constructor class works in the same manner as the standard [django forms](https://docs.djangoproject.com/en/dev/topics/forms/) and key bits used like form fields. Lets go through key construction steps for [DefaultKeyConstructor](#default-key-constructor). Firstly, constructor starts iteration over every key bit: * **unique\_method\_id** * **format** * **language** Then constructor gets data from every key bit calling method `get_data`: * **unique\_method\_id** - `u'your_app.views.SometView.get'` * **format** - `u'json'` * **language** - `u'en'` Every key bit `get_data` method is called with next arguments: * **view_instance** - view instance of decorated method * **view_method** - decorated method * **request** - decorated method request * **args** - decorated method positional arguments * **kwargs** - decorated method keyword arguments After this it combines every key bit data to one dict, which keys are a key bits names in constructor, and values are returned data: { 'unique_method_id': u'your_app.views.SometView.get', 'format': u'json', 'language': u'en' } Then constructor dumps resulting dict to json: '{"unique_method_id": "your_app.views.SometView.get", "language": "en", "format": "json"}' And finally compresses json with **md5** and returns hash value: 'b04f8f03c89df824e0ecd25230a90f0e0ebe184cf8c0114342e9471dd2275baa' #### Custom key bit We are going to create a simple key bit which could be used in real applications with next properties: * High read rate * Low write rate The task is - cache every read request and invalidate all cache data after write to any model, which used in API. This approach let us don't think about granular cache invalidation - just flush it after any model instance change/creation/deletion. Lets create models: # models.py from django.db import models class Group(models.Model): title = models.CharField() class Profile(models.Model): name = models.CharField() group = models.ForeignKey(Group) Define serializers: # serializers.py from yourapp.models import Group, Profile from rest_framework import serializers class GroupSerializer(serializers.ModelSerializer): class Meta: model = Group class ProfileSerializer(serializers.ModelSerializer): group = GroupSerializer() class Meta: model = Profile Create views: # views.py from yourapp.serializers import GroupSerializer, ProfileSerializer from yourapp.models import Group, Profile class GroupViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = GroupSerializer queryset = Group.objects.all() class ProfileViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = ProfileSerializer queryset = Profile.objects.all() And finally register views in router: # urls.py from yourapp.views import GroupViewSet,ProfileViewSet router = DefaultRouter() router.register(r'groups', GroupViewSet) router.register(r'profiles', ProfileViewSet) urlpatterns = router.urls At the moment we have API, but it's not cached. Lets cache it and create our custom key bit: # views.py import datetime from django.core.cache import cache from django.utils.encoding import force_str from yourapp.serializers import GroupSerializer, ProfileSerializer from rest_framework_extensions.cache.decorators import cache_response from rest_framework_extensions.key_constructor.constructors import ( DefaultKeyConstructor ) from rest_framework_extensions.key_constructor.bits import ( KeyBitBase, RetrieveSqlQueryKeyBit, ListSqlQueryKeyBit, PaginationKeyBit ) class UpdatedAtKeyBit(KeyBitBase): def get_data(self, **kwargs): key = 'api_updated_at_timestamp' value = cache.get(key, None) if not value: value = datetime.datetime.utcnow() cache.set(key, value=value) return force_str(value) class CustomObjectKeyConstructor(DefaultKeyConstructor): retrieve_sql = RetrieveSqlQueryKeyBit() updated_at = UpdatedAtKeyBit() class CustomListKeyConstructor(DefaultKeyConstructor): list_sql = ListSqlQueryKeyBit() pagination = PaginationKeyBit() updated_at = UpdatedAtKeyBit() class GroupViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = GroupSerializer @cache_response(key_func=CustomObjectKeyConstructor()) def retrieve(self, *args, **kwargs): return super(GroupViewSet, self).retrieve(*args, **kwargs) @cache_response(key_func=CustomListKeyConstructor()) def list(self, *args, **kwargs): return super(GroupViewSet, self).list(*args, **kwargs) class ProfileViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = ProfileSerializer @cache_response(key_func=CustomObjectKeyConstructor()) def retrieve(self, *args, **kwargs): return super(ProfileViewSet, self).retrieve(*args, **kwargs) @cache_response(key_func=CustomListKeyConstructor()) def list(self, *args, **kwargs): return super(ProfileViewSet, self).list(*args, **kwargs) As you can see `UpdatedAtKeyBit` just adds to key information when API models has been update last time. If there is no information about it then new datetime will be used for key bit data. Lets write cache invalidation. We just connect models to standard signals and change value in cache by key `api_updated_at_timestamp`: # models.py import datetime from django.db import models from django.db.models.signals import post_save, post_delete def change_api_updated_at(sender=None, instance=None, *args, **kwargs): cache.set('api_updated_at_timestamp', datetime.datetime.utcnow()) class Group(models.Model): title = models.CharField() class Profile(models.Model): name = models.CharField() group = models.ForeignKey(Group) for model in [Group, Profile]: post_save.connect(receiver=change_api_updated_at, sender=model) post_delete.connect(receiver=change_api_updated_at, sender=model) And that's it. When any model changes then value in cache by key `api_updated_at_timestamp` will be changed too. After this every key constructor, that used `UpdatedAtKeyBit`, will construct new keys and `@cache_response` decorator will cache data in new places. #### Key constructor params *New in DRF-extensions 0.2.3* You can change `params` attribute for specific key bit by providing `params` dict for key constructor initialization function. For example, here is custom key constructor, which inherits from [DefaultKeyConstructor](#default-key-constructor) and adds geoip key bit: class CityKeyConstructor(DefaultKeyConstructor): geoip = bits.RequestMetaKeyBit(params=['GEOIP_CITY']) If you wanted to use `GEOIP_COUNTRY`, you could create new key constructor: class CountryKeyConstructor(DefaultKeyConstructor): geoip = bits.RequestMetaKeyBit(params=['GEOIP_COUNTRY']) But there is another way. You can send `params` in key constructor initialization method. This is the dict attribute, where keys are bit names and values are bit `params` attribute value (look at `CountryView`): class CityKeyConstructor(DefaultKeyConstructor): geoip = bits.RequestMetaKeyBit(params=['GEOIP_CITY']) class CityView(views.APIView): @cache_response(key_func=CityKeyConstructor()) def get(self, request, *args, **kwargs): ... class CountryView(views.APIView): @cache_response(key_func=CityKeyConstructor( params={'geoip': ['GEOIP_COUNTRY']} )) def get(self, request, *args, **kwargs): ... If there is no item provided for key bit then default key bit `params` value will be used. #### Constructor's bits list You can dynamically change key constructor's bits list in initialization method by altering `bits` attribute: class CustomKeyConstructor(DefaultKeyConstructor): def __init__(self, *args, **kwargs): super(CustomKeyConstructor, self).__init__(*args, **kwargs) self.bits['geoip'] = bits.RequestMetaKeyBit( params=['GEOIP_CITY'] ) ### Default key bits Out of the box DRF-extensions has some basic key bits. They are all located in `rest_framework_extensions.key_constructor.bits` module. #### FormatKeyBit Retrieves format info from request. Usage example: class MyKeyConstructor(KeyConstructor): format = FormatKeyBit() #### LanguageKeyBit Retrieves active language for request. Usage example: class MyKeyConstructor(KeyConstructor): language = LanguageKeyBit() #### UserKeyBit Retrieves user id from request. If it is anonymous then returnes *"anonymous"* string. Usage example: class MyKeyConstructor(KeyConstructor): user = UserKeyBit() #### RequestMetaKeyBit Retrieves data from [request.META](https://docs.djangoproject.com/en/dev/ref/request-response/#django.http.HttpRequest.META) dict. Usage example: class MyKeyConstructor(KeyConstructor): ip_address_and_user_agent = bits.RequestMetaKeyBit( ['REMOTE_ADDR', 'HTTP_USER_AGENT'] ) You can use `*` for retrieving all meta data to key bit: *New in DRF-extensions 0.2.7* class MyKeyConstructor(KeyConstructor): all_request_meta = bits.RequestMetaKeyBit('*') #### HeadersKeyBit Same as `RequestMetaKeyBit` retrieves data from [request.META](https://docs.djangoproject.com/en/dev/ref/request-response/#django.http.HttpRequest.META) dict. The difference is that `HeadersKeyBit` allows to use normal header names: class MyKeyConstructor(KeyConstructor): user_agent_and_geobase_id = bits.HeadersKeyBit( ['user-agent', 'x-geobase-id'] ) # will process request.META['HTTP_USER_AGENT'] and # request.META['HTTP_X_GEOBASE_ID'] You can use `*` for retrieving all headers to key bit: *New in DRF-extensions 0.2.7* class MyKeyConstructor(KeyConstructor): all_headers = bits.HeadersKeyBit('*') #### ArgsKeyBit *New in DRF-extensions 0.2.7* Retrieves data from the view's positional arguments. A list of position indices can be passed to indicate which arguments to use. For retrieving all arguments you can use `*` which is also the default value: class MyKeyConstructor(KeyConstructor): args = bits.ArgsKeyBit() # will use all positional arguments class MyKeyConstructor(KeyConstructor): args = bits.ArgsKeyBit('*') # same as above class MyKeyConstructor(KeyConstructor): args = bits.ArgsKeyBit([0, 2]) #### KwargsKeyBit *New in DRF-extensions 0.2.7* Retrieves data from the views's keyword arguments. A list of keyword argument names can be passed to indicate which kwargs to use. For retrieving all kwargs you can use `*` which is also the default value: class MyKeyConstructor(KeyConstructor): kwargs = bits.KwargsKeyBit() # will use all keyword arguments class MyKeyConstructor(KeyConstructor): kwargs = bits.KwargsKeyBit('*') # same as above class MyKeyConstructor(KeyConstructor): kwargs = bits.KwargsKeyBit(['user_id', 'city']) #### QueryParamsKeyBit Retrieves data from [request.GET](https://docs.djangoproject.com/en/dev/ref/request-response/#django.http.HttpRequest.GET) dict. Usage example: class MyKeyConstructor(KeyConstructor): part_and_callback = bits.QueryParamsKeyBit( ['part', 'callback'] ) You can use `*` for retrieving all query params to key bit which is also the default value: *New in DRF-extensions 0.2.7* class MyKeyConstructor(KeyConstructor): all_query_params = bits.QueryParamsKeyBit('*') # all qs parameters class MyKeyConstructor(KeyConstructor): all_query_params = bits.QueryParamsKeyBit() # same as above #### PaginationKeyBit Inherits from `QueryParamsKeyBit` and returns data from used pagination params. class MyKeyConstructor(KeyConstructor): pagination = bits.PaginationKeyBit() #### ListSqlQueryKeyBit Retrieves sql query for `view.filter_queryset(view.get_queryset())` filtering. class MyKeyConstructor(KeyConstructor): list_sql_query = bits.ListSqlQueryKeyBit() #### RetrieveSqlQueryKeyBit Retrieves sql query for retrieving exact object. class MyKeyConstructor(KeyConstructor): retrieve_sql_query = bits.RetrieveSqlQueryKeyBit() #### UniqueViewIdKeyBit Combines data about view module and view class name. class MyKeyConstructor(KeyConstructor): unique_view_id = bits.UniqueViewIdKeyBit() #### UniqueMethodIdKeyBit Combines data about view module, view class name and view method name. class MyKeyConstructor(KeyConstructor): unique_view_id = bits.UniqueMethodIdKeyBit() #### ListModelKeyBit *New in DRF-extensions 0.3.2* Computes the semantic fingerprint of a list of objects returned by `view.filter_queryset(view.get_queryset())` using a flat representation of all objects' values. class MyKeyConstructor(KeyConstructor): list_model_values = bits.ListModelKeyBit() #### RetrieveModelKeyBit *New in DRF-extensions 0.3.2* Computes the semantic fingerprint of a particular objects returned by `view.get_object()`. class MyKeyConstructor(KeyConstructor): retrieve_model_values = bits.RetrieveModelKeyBit() ### Conditional requests The etag functionality is pending an overhaul has been temporarily removed since 0.4.0. See discussion in [Issue #177](https://github.com/chibisov/drf-extensions/issues/177) ### Bulk operations *New in DRF-extensions 0.2.4* Bulk operations allows you to perform operations over set of objects with one request. There is third-party package [django-rest-framework-bulk](django-rest-framework-bulk) with support for all CRUD methods, but it iterates over every instance in bulk operation, serializes it and only after that executes operation. It plays nice with `create` or `update` operations, but becomes unacceptable with `partial update` and `delete` methods over the `QuerySet`. Such kind of `QuerySet` could contain thousands of objects and should be performed as database query over the set at once. Please note - DRF-extensions bulk operations applies over `QuerySet`, not over instances. It means that: * No serializer's `save` or `delete` methods would be called * No viewset's `pre_save`, `post_save`, `pre_delete` and `post_delete` would be called * No model signals would be called #### Safety Bulk operations are very dangerous in case of making stupid mistakes. For example you wanted to delete user instance with `DELETE` request from your client application. # Request DELETE /users/1/ HTTP/1.1 Accept: application/json # Response HTTP/1.1 204 NO CONTENT Content-Type: application/json; charset=UTF-8 That was example of successful deletion. But there is the common situation when client could not get instance id and sends request to endpoint without it: # Request DELETE /users/ HTTP/1.1 Accept: application/json # Response HTTP/1.1 204 NO CONTENT Content-Type: application/json; charset=UTF-8 If you used [bulk destroy mixin](#bulk-destroy) for `/users/` endpoint, then all your user objects would be deleted. To protect from such confusions DRF-extensions asks you to send `X-BULK-OPERATION` header for every bulk operation request. With this protection previous example would not delete any user instances: # Request DELETE /users/ HTTP/1.1 Accept: application/json # Response HTTP/1.1 400 BAD REQUEST Content-Type: application/json; charset=UTF-8 { "detail": "Header 'X-BULK-OPERATION' should be provided for bulk operation." } With `X-BULK-OPERATION` header it works as expected - deletes all user instances: # Request DELETE /users/ HTTP/1.1 Accept: application/json X-BULK-OPERATION: true # Response HTTP/1.1 204 NO CONTENT Content-Type: application/json; charset=UTF-8 You can change bulk operation header name in settings: REST_FRAMEWORK_EXTENSIONS = { 'DEFAULT_BULK_OPERATION_HEADER_NAME': 'X-CUSTOM-BULK-OPERATION' } To turn off protection you can set `DEFAULT_BULK_OPERATION_HEADER_NAME` as `None`. #### Bulk destroy This mixin allows you to delete many instances with one `DELETE` request. from rest_framework_extensions.bulk_operations.mixins import ListDestroyModelMixin class UserViewSet(ListDestroyModelMixin, viewsets.ModelViewSet): serializer_class = UserSerializer Bulk destroy example - delete all users which emails ends with `gmail.com`: # Request DELETE /users/?email__endswith=gmail.com HTTP/1.1 Accept: application/json X-BULK-OPERATION: true # Response HTTP/1.1 204 NO CONTENT Content-Type: application/json; charset=UTF-8 #### Bulk update This mixin allows you to update many instances with one `PATCH` request. Note, that this mixin works only with partial update. from rest_framework_extensions.mixins import ListUpdateModelMixin class UserViewSet(ListUpdateModelMixin, viewsets.ModelViewSet): serializer_class = UserSerializer Bulk partial update example - set `email_provider` of every user as `google`, if it's email ends with `gmail.com`: # Request PATCH /users/?email__endswith=gmail.com HTTP/1.1 Accept: application/json X-BULK-OPERATION: true {"email_provider": "google"} # Response HTTP/1.1 204 NO CONTENT Content-Type: application/json; charset=UTF-8 ### Settings DRF-extensions follows Django Rest Framework approach in settings implementation. [In Django Rest Framework](http://www.django-rest-framework.org/api-guide/settings) you specify custom settings by changing `REST_FRAMEWORK` variable in settings file: REST_FRAMEWORK = { 'DEFAULT_RENDERER_CLASSES': ( 'rest_framework.renderers.YAMLRenderer', ), 'DEFAULT_PARSER_CLASSES': ( 'rest_framework.parsers.YAMLParser', ) } In DRF-extensions there is a magic variable too called `REST_FRAMEWORK_EXTENSIONS`: REST_FRAMEWORK_EXTENSIONS = { 'DEFAULT_CACHE_RESPONSE_TIMEOUT': 60 * 15 } #### Accessing settings If you need to access the values of DRF-extensions API settings in your project, you should use the `extensions_api_settings` object. For example: from rest_framework_extensions.settings import extensions_api_settings print extensions_api_settings.DEFAULT_CACHE_RESPONSE_TIMEOUT ### Release notes You can read about versioning, deprecation policy and upgrading from [Django REST framework documentation](https://www.django-rest-framework.org/community/release-notes/). #### Development version * Added support for Django 3.0 (#276) * Dropped support for Django 2.0 * Added support for DRF 3.10 and 3.11 (#261, #279) * Added support for Python 3.8 (#282) * Added paginate decorator (#266) * Added limit/offset and cursor pagination to PaginationKeyBit (#204) #### 0.5.0 *May 10, 2019* * Dropped python 2.7 and 3.4 * Fix possible header mutation issue * Added ability to [use a specific cache timeouts](#cacheresponsemixin) for `CacheResponseMixin` * Test against Django 2.1, DRF 3.9 and django-filter 2.0.0 * Dropped support of older DRF version lower than 3.9 * Django 2.2 support added #### 0.4.0 *Sep 5, 2018* * Added support for django 1.11 and 2.0 * Dropped support for django versions lower then 1.11 * Nested routes with over 2 levels now respect `lookup_value_regex` * Added support for DRF 3.8 * Dropped support of older DRF version lower then 3.8 * Cache only the renered response instead of rendering whole response object * The etag functionalties are not enabled by default, have to enable it manually #### 0.3.2 *Jan 4, 2017* * Added `rest_framework_extensions.exceptions.PreconditionRequiredException` as subclass of `rest_framework.exceptions.APIException` * Added `@api_etag` decorator function and `APIETAGProcessor` that uses *semantic* ETags per API resource, decoupled from views, such that it can be used in optimistic concurrency control * Added new default key bits `RetrieveModelKeyBit` and `ListModelKeyBit` for computing the semantic fingerprint of a django model instance * Added `APIETAGMixin` to be used in DRF viewsets and views * Added new settings for default implementation of the API ETag functions: `DEFAULT_API_OBJECT_ETAG_FUNC`, `DEFAULT_API_LIST_ETAG_FUNC` * Added test application for functional tests and demo as `tests_app/tests/functional/concurrency/conditional_request` * Added unit tests for the `@api_etag` decorator * DRF 3.5.x, Django pre-1.10 compatibility of the key bit construction * (Test-)Code cleanup #### 0.3.1 *Sep 29, 2016* * Fix `schema_urls` `ExtendedDefaultRouter` compatibility issue introduced by DRF 3.4.0 * Removed deprecated @action() and @link() decorators * DRF 3.4.x compatibility * Django 1.9 and 1.10 compatibility #### 0.2.8 *Sep 21, 2015* * Fixed `ListSqlQueryKeyBit` and `RetrieveSqlQueryKeyBit` [problems](https://github.com/chibisov/drf-extensions/issues/28) with `EmptyResultSet` exception ([pull](https://github.com/chibisov/drf-extensions/pull/75/)). * All items are now by default in [ArgsKeyBit](#argskeybit), [KwargsKeyBit](#kwargskeybit) and [QueryParamsKeyBit](#queryparamskeybit) * Respect parent lookup regex value for [Nested routes](#nested-routes) ([issue](https://github.com/chibisov/drf-extensions/pull/87)). #### 0.2.7 *Feb 2, 2015* * [DRF 3.x compatibility](https://github.com/chibisov/drf-extensions/issues/39) * [DetailSerializerMixin](#detailserializermixin) is now [compatible with DRF 3.0](https://github.com/chibisov/drf-extensions/issues/46) * Added [ArgsKeyBit](#argskeybit) * Added [KwargsKeyBit](#kwargskeybit) * Fixed [PartialUpdateSerializerMixin](#partialupdateserializermixin) [compatibility issue with DRF 3.x](https://github.com/chibisov/drf-extensions/issues/66) * Added [cache_errors](#caching-errors) attribute for switching caching for error responses * Added ability to specify usage of all items for [RequestMetaKeyBit](#requestmetakeybit), [HeadersKeyBit](#headerskeybit) and [QueryParamsKeyBit](#queryparamskeybit) providing `params='*'` * [Collection level controllers](#collection-level-controllers) is in pending deprecation * [Controller endpoint name](#controller-endpoint-name) is in pending deprecation #### 0.2.6 *Sep 9, 2014* * Usage of [django.core.cache.caches](https://docs.djangoproject.com/en/dev/topics/cache/#django.core.cache.caches) for django >= 1.7 * [Documented ETag usage with GZipMiddleware](#gzipped-etags) * Fixed `ListSqlQueryKeyBit` and `RetrieveSqlQueryKeyBit` [problems](https://github.com/chibisov/drf-extensions/issues/28) with `EmptyResultSet`. * Fixed [cache response](#cache-response) compatibility [issue](https://github.com/chibisov/drf-extensions/issues/32) with DRF 2.4.x #### 0.2.5 *July 9, 2014* * Fixed [setuptools confusion with pyc files](https://github.com/chibisov/drf-extensions/issues/20) #### 0.2.4 *July 7, 2014* * Added tests for [Django REST Framework 2.3.14](http://www.django-rest-framework.org/topics/release-notes#2314) * Added [Bulk operations](#bulk-operations) * Fixed [extended routers](#routers) compatibility issue with [default controller decorators](http://www.django-rest-framework.org/api-guide/viewsets#marking-extra-methods-for-routing) * Documented [pluggable router mixins](#pluggable-router-mixins) * Added [nested routes](#nested-routes) #### 0.2.3 *Apr. 25, 2014* * Added [PartialUpdateSerializerMixin](#partialupdateserializermixin) * Added [Key constructor params](#key-constructor-params) * Documented dynamically [constructor's bits list](#constructor-s-bits-list) altering * Added ability to [use a specific cache](#usage-of-the-specific-cache) for `@cache_response` decorator #### 0.2.2 *Mar. 23, 2014* * Added [PaginateByMaxMixin](#paginatebymaxmixin) * Added [ExtenedDjangoObjectPermissions](#object-permissions) * Added tests for django 1.7 #### 0.2.1 *Feb. 1, 2014* * Rewritten tests to nose and tox * New tests directory structure * Rewritten HTTP documentation requests examples into more raw manner * Added trailing_slash on extended routers for Django Rest Framework versions`>=2.3.6` (which supports this feature) * Added [caching](#caching) * Added [key constructor](#key-constructor) * Added [conditional requests](#conditional-requests) with Etag calculation * Added [Cache/ETAG mixins](#cache-etag-mixins) * Added [CacheResponseMixin](#cacheresponsemixin) * Added [ETAGMixin](#etagmixin) * Documented [ResourceUriField](#resourceurifield) * Documented [settings](#settings) customization #### 0.2 *Nov. 5, 2013* * Moved docs from readme to github pages * Docs generation with [Backdoc](https://github.com/chibisov/backdoc) drf-extensions-0.6.0/rest_framework_extensions/000077500000000000000000000000001361353156700220105ustar00rootroot00000000000000drf-extensions-0.6.0/rest_framework_extensions/__init__.py000066400000000000000000000000731361353156700241210ustar00rootroot00000000000000__version__ = '0.6.0' # from 0.5.0 VERSION = __version__ drf-extensions-0.6.0/rest_framework_extensions/bulk_operations/000077500000000000000000000000001361353156700252105ustar00rootroot00000000000000drf-extensions-0.6.0/rest_framework_extensions/bulk_operations/__init__.py000066400000000000000000000000001361353156700273070ustar00rootroot00000000000000drf-extensions-0.6.0/rest_framework_extensions/bulk_operations/mixins.py000066400000000000000000000077121361353156700271000ustar00rootroot00000000000000from django.utils.encoding import force_str from rest_framework import status from rest_framework.response import Response from rest_framework_extensions.settings import extensions_api_settings from rest_framework_extensions import utils class BulkOperationBaseMixin: def is_object_operation(self): return bool(self.get_object_lookup_value()) def get_object_lookup_value(self): return self.kwargs.get(getattr(self, 'lookup_url_kwarg', None) or self.lookup_field, None) def is_valid_bulk_operation(self): if extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME: header_name = utils.prepare_header_name( extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME) return bool(self.request.META.get(header_name, None)), { 'detail': 'Header \'{0}\' should be provided for bulk operation.'.format( extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME ) } else: return True, {} class ListDestroyModelMixin(BulkOperationBaseMixin): def delete(self, request, *args, **kwargs): if self.is_object_operation(): return super().destroy(request, *args, **kwargs) else: return self.destroy_bulk(request, *args, **kwargs) def destroy_bulk(self, request, *args, **kwargs): is_valid, errors = self.is_valid_bulk_operation() if is_valid: queryset = self.filter_queryset(self.get_queryset()) self.pre_delete_bulk(queryset) # todo: test and document me queryset.delete() self.post_delete_bulk(queryset) # todo: test and document me return Response(status=status.HTTP_204_NO_CONTENT) else: return Response(errors, status=status.HTTP_400_BAD_REQUEST) def pre_delete_bulk(self, queryset): """ Placeholder method for calling before deleting an queryset. """ pass def post_delete_bulk(self, queryset): """ Placeholder method for calling after deleting an queryset. """ pass class ListUpdateModelMixin(BulkOperationBaseMixin): def patch(self, request, *args, **kwargs): if self.is_object_operation(): return super().partial_update(request, *args, **kwargs) else: return self.partial_update_bulk(request, *args, **kwargs) def partial_update_bulk(self, request, *args, **kwargs): is_valid, errors = self.is_valid_bulk_operation() if is_valid: queryset = self.filter_queryset(self.get_queryset()) update_bulk_dict = self.get_update_bulk_dict( serializer=self.get_serializer_class()(), data=request.data) # todo: test and document me self.pre_save_bulk(queryset, update_bulk_dict) try: queryset.update(**update_bulk_dict) except ValueError as e: errors = { 'detail': force_str(e) } return Response(errors, status=status.HTTP_400_BAD_REQUEST) # todo: test and document me self.post_save_bulk(queryset, update_bulk_dict) return Response(status=status.HTTP_204_NO_CONTENT) else: return Response(errors, status=status.HTTP_400_BAD_REQUEST) def get_update_bulk_dict(self, serializer, data): update_bulk_dict = {} for field_name, field in serializer.fields.items(): if field_name in data and not field.read_only: update_bulk_dict[field.source or field_name] = data[field_name] return update_bulk_dict def pre_save_bulk(self, queryset, update_bulk_dict): """ Placeholder method for calling before deleting an queryset. """ pass def post_save_bulk(self, queryset, update_bulk_dict): """ Placeholder method for calling after deleting an queryset. """ pass drf-extensions-0.6.0/rest_framework_extensions/cache/000077500000000000000000000000001361353156700230535ustar00rootroot00000000000000drf-extensions-0.6.0/rest_framework_extensions/cache/__init__.py000066400000000000000000000000001361353156700251520ustar00rootroot00000000000000drf-extensions-0.6.0/rest_framework_extensions/cache/decorators.py000066400000000000000000000101511361353156700255700ustar00rootroot00000000000000from functools import wraps, WRAPPER_ASSIGNMENTS from django.http.response import HttpResponse from rest_framework_extensions.settings import extensions_api_settings def get_cache(alias): from django.core.cache import caches return caches[alias] class CacheResponse: """ Store/Receive and return cached `HttpResponse` based on DRF response. .. note:: This decorator will render and discard the original DRF response in favor of Django's `HttpResponse`. The allows the cache to retain a smaller memory footprint and eliminates the need to re-render responses on each request. Furthermore it eliminates the risk for users to unknowingly cache whole Serializers and QuerySets. """ def __init__(self, timeout=None, key_func=None, cache=None, cache_errors=None): if timeout is None: self.timeout = extensions_api_settings.DEFAULT_CACHE_RESPONSE_TIMEOUT else: self.timeout = timeout if key_func is None: self.key_func = extensions_api_settings.DEFAULT_CACHE_KEY_FUNC else: self.key_func = key_func if cache_errors is None: self.cache_errors = extensions_api_settings.DEFAULT_CACHE_ERRORS else: self.cache_errors = cache_errors self.cache = get_cache(cache or extensions_api_settings.DEFAULT_USE_CACHE) def __call__(self, func): this = self @wraps(func, assigned=WRAPPER_ASSIGNMENTS) def inner(self, request, *args, **kwargs): return this.process_cache_response( view_instance=self, view_method=func, request=request, args=args, kwargs=kwargs, ) return inner def process_cache_response(self, view_instance, view_method, request, args, kwargs): key = self.calculate_key( view_instance=view_instance, view_method=view_method, request=request, args=args, kwargs=kwargs ) timeout = self.calculate_timeout(view_instance=view_instance) response_triple = self.cache.get(key) if not response_triple: # render response to create and cache the content byte string response = view_method(view_instance, request, *args, **kwargs) response = view_instance.finalize_response(request, response, *args, **kwargs) response.render() if not response.status_code >= 400 or self.cache_errors: response_triple = ( response.rendered_content, response.status_code, response._headers.copy() ) self.cache.set(key, response_triple, timeout) else: # build smaller Django HttpResponse content, status, headers = response_triple response = HttpResponse(content=content, status=status) for k, v in headers.values(): response[k] = v if not hasattr(response, '_closable_objects'): response._closable_objects = [] return response def calculate_key(self, view_instance, view_method, request, args, kwargs): if isinstance(self.key_func, str): key_func = getattr(view_instance, self.key_func) else: key_func = self.key_func return key_func( view_instance=view_instance, view_method=view_method, request=request, args=args, kwargs=kwargs, ) def calculate_timeout(self, view_instance, **_): if isinstance(self.timeout, str): self.timeout = getattr(view_instance, self.timeout) return self.timeout cache_response = CacheResponse drf-extensions-0.6.0/rest_framework_extensions/cache/mixins.py000066400000000000000000000023231361353156700247340ustar00rootroot00000000000000from rest_framework_extensions.cache.decorators import cache_response from rest_framework_extensions.settings import extensions_api_settings class BaseCacheResponseMixin: # todo: test me. Create generic test like # test_cache_reponse(view_instance, method, should_rebuild_after_method_evaluation) object_cache_key_func = extensions_api_settings.DEFAULT_OBJECT_CACHE_KEY_FUNC list_cache_key_func = extensions_api_settings.DEFAULT_LIST_CACHE_KEY_FUNC object_cache_timeout = extensions_api_settings.DEFAULT_CACHE_RESPONSE_TIMEOUT list_cache_timeout = extensions_api_settings.DEFAULT_CACHE_RESPONSE_TIMEOUT class ListCacheResponseMixin(BaseCacheResponseMixin): @cache_response(key_func='list_cache_key_func', timeout='list_cache_timeout') def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) class RetrieveCacheResponseMixin(BaseCacheResponseMixin): @cache_response(key_func='object_cache_key_func', timeout='object_cache_timeout') def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) class CacheResponseMixin(RetrieveCacheResponseMixin, ListCacheResponseMixin): pass drf-extensions-0.6.0/rest_framework_extensions/compat.py000066400000000000000000000010571361353156700236500ustar00rootroot00000000000000""" The `compat` module provides support for backwards compatibility with older versions of django/python, and compatibility wrappers around optional packages. """ # handle different QuerySet representations def queryset_to_value_list(queryset): assert isinstance(queryset, str) # django 1.10 introduces syntax "" # we extract only the list of tuples from the string idx_bracket_open = queryset.find(u'[') idx_bracket_close = queryset.rfind(u']') return queryset[idx_bracket_open:idx_bracket_close + 1] drf-extensions-0.6.0/rest_framework_extensions/decorators.py000066400000000000000000000013421361353156700245270ustar00rootroot00000000000000def paginate(pagination_class=None, **kwargs): """ Decorator that adds a pagination_class to GenericViewSet class. Custom pagination class also available. Usage : from rest_framework.pagination import CursorPagination @paginate(pagination_class=CursorPagination, page_size=5, ordering='-created_at') class FooViewSet(viewsets.GenericViewSet): ... """ assert pagination_class is not None, ( "@paginate missing required argument: 'pagination_class'" ) class _Pagination(pagination_class): def __init__(self): self.__dict__.update(kwargs) def decorator(_class): _class.pagination_class = _Pagination return _class return decorator drf-extensions-0.6.0/rest_framework_extensions/etag/000077500000000000000000000000001361353156700227305ustar00rootroot00000000000000drf-extensions-0.6.0/rest_framework_extensions/etag/__init__.py000066400000000000000000000000011361353156700250300ustar00rootroot00000000000000 drf-extensions-0.6.0/rest_framework_extensions/etag/decorators.py000066400000000000000000000215121361353156700254500ustar00rootroot00000000000000import logging from functools import wraps, WRAPPER_ASSIGNMENTS from django.utils.http import parse_etags, quote_etag from rest_framework import status from rest_framework.permissions import SAFE_METHODS from rest_framework.response import Response from rest_framework_extensions.exceptions import PreconditionRequiredException from rest_framework_extensions.utils import prepare_header_name from rest_framework_extensions.settings import extensions_api_settings logger = logging.getLogger('django.request') class ETAGProcessor: """Based on https://github.com/django/django/blob/master/django/views/decorators/http.py""" def __init__(self, etag_func=None, rebuild_after_method_evaluation=False): if not etag_func: etag_func = extensions_api_settings.DEFAULT_ETAG_FUNC self.etag_func = etag_func self.rebuild_after_method_evaluation = rebuild_after_method_evaluation def __call__(self, func): this = self @wraps(func, assigned=WRAPPER_ASSIGNMENTS) def inner(self, request, *args, **kwargs): return this.process_conditional_request( view_instance=self, view_method=func, request=request, args=args, kwargs=kwargs, ) return inner def process_conditional_request(self, view_instance, view_method, request, args, kwargs): etags, if_none_match, if_match = self.get_etags_and_matchers(request) res_etag = self.calculate_etag( view_instance=view_instance, view_method=view_method, request=request, args=args, kwargs=kwargs, ) if self.is_if_none_match_failed(res_etag, etags, if_none_match): if request.method in SAFE_METHODS: response = Response(status=status.HTTP_304_NOT_MODIFIED) else: response = self._get_and_log_precondition_failed_response( request=request) elif self.is_if_match_failed(res_etag, etags, if_match): response = self._get_and_log_precondition_failed_response( request=request) else: response = view_method(view_instance, request, *args, **kwargs) if self.rebuild_after_method_evaluation: res_etag = self.calculate_etag( view_instance=view_instance, view_method=view_method, request=request, args=args, kwargs=kwargs, ) if res_etag and not response.has_header('ETag'): response['ETag'] = quote_etag(res_etag) return response def get_etags_and_matchers(self, request): etags = None if_none_match = request.META.get(prepare_header_name("if-none-match")) if_match = request.META.get(prepare_header_name("if-match")) if if_none_match or if_match: # There can be more than one ETag in the request, so we # consider the list of values. try: etags = parse_etags(if_none_match or if_match) except ValueError: # In case of invalid etag ignore all ETag headers. # Apparently Opera sends invalidly quoted headers at times # (we should be returning a 400 response, but that's a # little extreme) -- this is Django bug #10681. if_none_match = None if_match = None return etags, if_none_match, if_match def calculate_etag(self, view_instance, view_method, request, args, kwargs): if isinstance(self.etag_func, str): etag_func = getattr(view_instance, self.etag_func) else: etag_func = self.etag_func return etag_func( view_instance=view_instance, view_method=view_method, request=request, args=args, kwargs=kwargs, ) def is_if_none_match_failed(self, res_etag, etags, if_none_match): if res_etag and if_none_match: etags = [etag.strip('"') for etag in etags] return res_etag in etags or '*' in etags else: return False def is_if_match_failed(self, res_etag, etags, if_match): if res_etag and if_match: return res_etag not in etags and '*' not in etags else: return False def _get_and_log_precondition_failed_response(self, request): logger.warning('Precondition Failed: %s', request.path, extra={ 'status_code': status.HTTP_412_PRECONDITION_FAILED, 'request': request } ) return Response(status=status.HTTP_412_PRECONDITION_FAILED) class APIETAGProcessor(ETAGProcessor): """ This class is responsible for calculating the ETag value given (a list of) model instance(s). It does not make sense to compute a default ETag here, because the processor would always issue a 304 response, even if the response was modified meanwhile. Therefore the `APIETAGProcessor` cannot be used without specifying an `etag_func` as keyword argument. According to RFC 6585, conditional headers may be enforced for certain services that support conditional requests. For optimistic locking, the server should respond status code 428 including a description on how to resubmit the request successfully, see https://tools.ietf.org/html/rfc6585#section-3. """ # require a pre-conditional header (e.g. If-Match) for unsafe HTTP methods (RFC 6585) # override this defaults, if required precondition_map = {'PUT': ['If-Match'], 'PATCH': ['If-Match'], 'DELETE': ['If-Match']} def __init__(self, etag_func=None, rebuild_after_method_evaluation=False, precondition_map=None): assert etag_func is not None, ('None-type functions are not allowed for processing API ETags.' 'You must specify a proper function to calculate the API ETags ' 'using the "etag_func" keyword argument.') if precondition_map is not None: self.precondition_map = precondition_map assert isinstance(self.precondition_map, dict), ('`precondition_map` must be a dict, where ' 'the key is the HTTP verb, and the value is a list of ' 'HTTP headers that must all be present for that request.') super().__init__(etag_func=etag_func, rebuild_after_method_evaluation=rebuild_after_method_evaluation) def get_etags_and_matchers(self, request): """Get the etags from the header and perform a validation against the required preconditions.""" # evaluate the preconditions, raises 428 if condition is not met self.evaluate_preconditions(request) # alright, headers are present, extract the values and match the conditions return super().get_etags_and_matchers(request) def evaluate_preconditions(self, request): """Evaluate whether the precondition for the request is met.""" if request.method.upper() in self.precondition_map.keys(): required_headers = self.precondition_map.get( request.method.upper(), []) # check the required headers for header in required_headers: if not request.META.get(prepare_header_name(header)): # raise an error for each header that does not match logger.warning('Precondition required: %s', request.path, extra={ 'status_code': status.HTTP_428_PRECONDITION_REQUIRED, 'request': request } ) # raise an RFC 6585 compliant exception raise PreconditionRequiredException(detail='Precondition required. This "%s" request ' 'is required to be conditional. ' 'Try again using "%s".' % ( request.method, header) ) return True etag = ETAGProcessor api_etag = APIETAGProcessor drf-extensions-0.6.0/rest_framework_extensions/etag/mixins.py000066400000000000000000000054471361353156700246230ustar00rootroot00000000000000from rest_framework_extensions.etag.decorators import etag, api_etag from rest_framework_extensions.settings import extensions_api_settings class BaseETAGMixin: # todo: test me. Create generic test like test_etag(view_instance, # method, should_rebuild_after_method_evaluation) object_etag_func = extensions_api_settings.DEFAULT_OBJECT_ETAG_FUNC list_etag_func = extensions_api_settings.DEFAULT_LIST_ETAG_FUNC class ListETAGMixin(BaseETAGMixin): @etag(etag_func='list_etag_func') def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) class RetrieveETAGMixin(BaseETAGMixin): @etag(etag_func='object_etag_func') def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) class UpdateETAGMixin(BaseETAGMixin): @etag(etag_func='object_etag_func', rebuild_after_method_evaluation=True) def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs) class DestroyETAGMixin(BaseETAGMixin): @etag(etag_func='object_etag_func') def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) class ReadOnlyETAGMixin(RetrieveETAGMixin, ListETAGMixin): pass class ETAGMixin(RetrieveETAGMixin, UpdateETAGMixin, DestroyETAGMixin, ListETAGMixin): pass class APIBaseETAGMixin: # todo: test me. Create generic test like test_etag(view_instance, # method, should_rebuild_after_method_evaluation) api_object_etag_func = extensions_api_settings.DEFAULT_API_OBJECT_ETAG_FUNC api_list_etag_func = extensions_api_settings.DEFAULT_API_LIST_ETAG_FUNC class APIListETAGMixin(APIBaseETAGMixin): @api_etag(etag_func='api_list_etag_func') def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) class APIRetrieveETAGMixin(APIBaseETAGMixin): @api_etag(etag_func='api_object_etag_func') def retrieve(self, request, *args, **kwargs): return super().retrieve(request, *args, **kwargs) class APIUpdateETAGMixin(APIBaseETAGMixin): @api_etag(etag_func='api_object_etag_func', rebuild_after_method_evaluation=True) def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs) class APIDestroyETAGMixin(APIBaseETAGMixin): @api_etag(etag_func='api_object_etag_func') def destroy(self, request, *args, **kwargs): return super().destroy(request, *args, **kwargs) class APIReadOnlyETAGMixin(APIRetrieveETAGMixin, APIListETAGMixin): pass class APIETAGMixin(APIRetrieveETAGMixin, APIUpdateETAGMixin, APIDestroyETAGMixin, APIListETAGMixin): pass drf-extensions-0.6.0/rest_framework_extensions/exceptions.py000066400000000000000000000005651361353156700245510ustar00rootroot00000000000000from django.utils.translation import gettext_lazy as _ from rest_framework import status from rest_framework.exceptions import APIException class PreconditionRequiredException(APIException): status_code = status.HTTP_428_PRECONDITION_REQUIRED default_detail = _('This "{method}" request is required to be conditional.') default_code = 'precondition_required' drf-extensions-0.6.0/rest_framework_extensions/fields.py000066400000000000000000000013521361353156700236310ustar00rootroot00000000000000from rest_framework.relations import HyperlinkedRelatedField class ResourceUriField(HyperlinkedRelatedField): """ Represents a hyperlinking uri that points to the detail view for that object. Example: class SurveySerializer(serializers.ModelSerializer): resource_uri = ResourceUriField(view_name='survey-detail') class Meta: model = Survey fields = ('id', 'resource_uri') ... { "id": 1, "resource_uri": "http://localhost/v1/surveys/1/", } """ # todo: test me read_only = True def __init__(self, *args, **kwargs): kwargs.setdefault('source', '*') super().__init__(*args, **kwargs) drf-extensions-0.6.0/rest_framework_extensions/key_constructor/000077500000000000000000000000001361353156700252455ustar00rootroot00000000000000drf-extensions-0.6.0/rest_framework_extensions/key_constructor/__init__.py000066400000000000000000000000001361353156700273440ustar00rootroot00000000000000drf-extensions-0.6.0/rest_framework_extensions/key_constructor/bits.py000066400000000000000000000202221361353156700265560ustar00rootroot00000000000000from django.utils.translation import get_language from django.db.models.query import EmptyQuerySet from django.db.models.sql.datastructures import EmptyResultSet from django.utils.encoding import force_str from rest_framework_extensions import compat class AllArgsMixin: def __init__(self, params='*'): super().__init__(params) class KeyBitBase: def __init__(self, params=None): self.params = params def get_data(self, params, view_instance, view_method, request, args, kwargs): """ @rtype: dict """ raise NotImplementedError() class KeyBitDictBase(KeyBitBase): """Base class for dict-like source data processing. Look at HeadersKeyBit and QueryParamsKeyBit """ def get_data(self, params, view_instance, view_method, request, args, kwargs): data = {} if params is not None: source_dict = self.get_source_dict( params=params, view_instance=view_instance, view_method=view_method, request=request, args=args, kwargs=kwargs ) if params == '*': params = source_dict.keys() for key in params: value = source_dict.get( self.prepare_key_for_value_retrieving(key)) if value is not None: data[self.prepare_key_for_value_assignment( key)] = force_str(value) return data def get_source_dict(self, params, view_instance, view_method, request, args, kwargs): raise NotImplementedError() def prepare_key_for_value_retrieving(self, key): return key def prepare_key_for_value_assignment(self, key): return key class UniqueViewIdKeyBit(KeyBitBase): def get_data(self, params, view_instance, view_method, request, args, kwargs): return '.'.join([ view_instance.__module__, view_instance.__class__.__name__ ]) class UniqueMethodIdKeyBit(KeyBitBase): def get_data(self, params, view_instance, view_method, request, args, kwargs): return '.'.join([ view_instance.__module__, view_instance.__class__.__name__, view_method.__name__ ]) class LanguageKeyBit(KeyBitBase): """ Return example: 'en' """ def get_data(self, params, view_instance, view_method, request, args, kwargs): return force_str(get_language()) class FormatKeyBit(KeyBitBase): """ Return example for json: u'json' Return example for html: u'html' """ def get_data(self, params, view_instance, view_method, request, args, kwargs): return force_str(request.accepted_renderer.format) class UserKeyBit(KeyBitBase): """ Return example for anonymous: u'anonymous' Return example for authenticated (value is user id): u'10' """ def get_data(self, params, view_instance, view_method, request, args, kwargs): if hasattr(request, 'user') and request.user and request.user.is_authenticated: return force_str(self._get_id_from_user(request.user)) else: return 'anonymous' def _get_id_from_user(self, user): return user.id class HeadersKeyBit(KeyBitDictBase): """ Return example: {'accept-language': u'ru', 'x-geobase-id': '123'} """ def get_source_dict(self, params, view_instance, view_method, request, args, kwargs): return request.META def prepare_key_for_value_retrieving(self, key): from rest_framework_extensions.utils import prepare_header_name # Accept-Language => http_accept_language return prepare_header_name(key.lower()) def prepare_key_for_value_assignment(self, key): return key.lower() # Accept-Language => accept-language class RequestMetaKeyBit(KeyBitDictBase): """ Return example: {'REMOTE_ADDR': u'127.0.0.2', 'REMOTE_HOST': u'yandex.ru'} """ def get_source_dict(self, params, view_instance, view_method, request, args, kwargs): return request.META class QueryParamsKeyBit(AllArgsMixin, KeyBitDictBase): """ Return example: {'part': 'Londo', 'callback': 'jquery_callback'} """ def get_source_dict(self, params, view_instance, view_method, request, args, kwargs): return request.GET class PaginationKeyBit(QueryParamsKeyBit): """ Return example: {'page_size': 100, 'page': '1'} """ paginator_attrs = [ 'page_query_param', 'page_size_query_param', 'limit_query_param', 'offset_query_param', 'cursor_query_param', ] def get_data(self, **kwargs): kwargs['params'] = [] paginator = getattr(kwargs['view_instance'], 'paginator', None) if paginator: for attr in self.paginator_attrs: param = getattr(paginator, attr, None) if param: kwargs['params'].append(param) return super().get_data(**kwargs) class SqlQueryKeyBitBase(KeyBitBase): def _get_queryset_query_string(self, queryset): if isinstance(queryset, EmptyQuerySet): return None else: try: return force_str(queryset.query.__str__()) except EmptyResultSet: return None class ModelInstanceKeyBitBase(KeyBitBase): """ Return the actual contents of the query set. This class is similar to the `SqlQueryKeyBitBase`. """ def _get_queryset_query_values(self, queryset): if isinstance(queryset, EmptyQuerySet) or queryset.count() == 0: return None else: try: # run through the instances and collect all values in ordered fashion return compat.queryset_to_value_list(force_str(queryset.values_list())) except EmptyResultSet: return None class ListSqlQueryKeyBit(SqlQueryKeyBitBase): def get_data(self, params, view_instance, view_method, request, args, kwargs): queryset = view_instance.filter_queryset(view_instance.get_queryset()) return self._get_queryset_query_string(queryset) class RetrieveSqlQueryKeyBit(SqlQueryKeyBitBase): def get_data(self, params, view_instance, view_method, request, args, kwargs): lookup_value = view_instance.kwargs[view_instance.lookup_field] try: queryset = view_instance.filter_queryset(view_instance.get_queryset()).filter( **{view_instance.lookup_field: lookup_value} ) except ValueError: return None else: return self._get_queryset_query_string(queryset) class RetrieveModelKeyBit(ModelInstanceKeyBitBase): """ A key bit reflecting the contents of the model instance. Return example: u"[(3, False)]" """ def get_data(self, params, view_instance, view_method, request, args, kwargs): lookup_value = view_instance.kwargs[view_instance.lookup_field] try: queryset = view_instance.filter_queryset(view_instance.get_queryset()).filter( **{view_instance.lookup_field: lookup_value} ) except ValueError: return None else: return self._get_queryset_query_values(queryset) class ListModelKeyBit(ModelInstanceKeyBitBase): """ A key bit reflecting the contents of a list of model instances. Return example: u"[(1, True), (2, True), (3, False)]" """ def get_data(self, params, view_instance, view_method, request, args, kwargs): queryset = view_instance.filter_queryset(view_instance.get_queryset()) return self._get_queryset_query_values(queryset) class ArgsKeyBit(AllArgsMixin, KeyBitBase): def get_data(self, params, view_instance, view_method, request, args, kwargs): if params == '*': return args elif params is not None: return [args[i] for i in params] else: return [] class KwargsKeyBit(AllArgsMixin, KeyBitDictBase): def get_source_dict(self, params, view_instance, view_method, request, args, kwargs): return kwargs drf-extensions-0.6.0/rest_framework_extensions/key_constructor/constructors.py000066400000000000000000000101561361353156700303720ustar00rootroot00000000000000import hashlib import json from rest_framework_extensions.key_constructor import bits from rest_framework_extensions.settings import extensions_api_settings class KeyConstructor: def __init__(self, memoize_for_request=None, params=None): if memoize_for_request is None: self.memoize_for_request = extensions_api_settings.DEFAULT_KEY_CONSTRUCTOR_MEMOIZE_FOR_REQUEST else: self.memoize_for_request = memoize_for_request if params is None: self.params = {} else: self.params = params self.bits = self.get_bits() def get_bits(self): _bits = {} for attr in dir(self.__class__): attr_value = getattr(self.__class__, attr) if isinstance(attr_value, bits.KeyBitBase): _bits[attr] = attr_value return _bits def __call__(self, **kwargs): return self.get_key(**kwargs) def get_key(self, view_instance, view_method, request, args, kwargs): if self.memoize_for_request: memoization_key = self._get_memoization_key( view_instance=view_instance, view_method=view_method, args=args, kwargs=kwargs ) if not hasattr(request, '_key_constructor_cache'): request._key_constructor_cache = {} if self.memoize_for_request and memoization_key in request._key_constructor_cache: return request._key_constructor_cache.get(memoization_key) else: value = self._get_key( view_instance=view_instance, view_method=view_method, request=request, args=args, kwargs=kwargs ) if self.memoize_for_request: request._key_constructor_cache[memoization_key] = value return value def _get_memoization_key(self, view_instance, view_method, args, kwargs): from rest_framework_extensions.utils import get_unique_method_id return json.dumps({ 'unique_method_id': get_unique_method_id(view_instance=view_instance, view_method=view_method), 'args': args, 'kwargs': kwargs, 'instance_id': id(self) }) def _get_key(self, view_instance, view_method, request, args, kwargs): _kwargs = { 'view_instance': view_instance, 'view_method': view_method, 'request': request, 'args': args, 'kwargs': kwargs, } return self.prepare_key( self.get_data_from_bits(**_kwargs) ) def prepare_key(self, key_dict): return hashlib.md5(json.dumps(key_dict, sort_keys=True).encode('utf-8')).hexdigest() def get_data_from_bits(self, **kwargs): result_dict = {} for bit_name, bit_instance in self.bits.items(): if bit_name in self.params: params = self.params[bit_name] else: try: params = bit_instance.params except AttributeError: params = None result_dict[bit_name] = bit_instance.get_data( params=params, **kwargs) return result_dict class DefaultKeyConstructor(KeyConstructor): unique_method_id = bits.UniqueMethodIdKeyBit() format = bits.FormatKeyBit() language = bits.LanguageKeyBit() class DefaultObjectKeyConstructor(DefaultKeyConstructor): retrieve_sql_query = bits.RetrieveSqlQueryKeyBit() class DefaultListKeyConstructor(DefaultKeyConstructor): list_sql_query = bits.ListSqlQueryKeyBit() pagination = bits.PaginationKeyBit() class DefaultAPIModelInstanceKeyConstructor(KeyConstructor): """ Use this constructor when the values of the model instance are required to identify the resource. """ retrieve_model_values = bits.RetrieveModelKeyBit() class DefaultAPIModelListKeyConstructor(KeyConstructor): """ Use this constructor when the values of the model instance are required to identify many resources. """ list_model_values = bits.ListModelKeyBit() drf-extensions-0.6.0/rest_framework_extensions/mixins.py000066400000000000000000000054601361353156700236760ustar00rootroot00000000000000from rest_framework_extensions.cache.mixins import CacheResponseMixin # from rest_framework_extensions.etag.mixins import ReadOnlyETAGMixin, ETAGMixin from rest_framework_extensions.bulk_operations.mixins import ListUpdateModelMixin, ListDestroyModelMixin from rest_framework_extensions.settings import extensions_api_settings from django.http import Http404 class DetailSerializerMixin: """ Add custom serializer for detail view """ serializer_detail_class = None queryset_detail = None def get_serializer_class(self): error_message = "'{0}' should include a 'serializer_detail_class' attribute".format( self.__class__.__name__) assert self.serializer_detail_class is not None, error_message if self._is_request_to_detail_endpoint(): return self.serializer_detail_class else: return super().get_serializer_class() def get_queryset(self, *args, **kwargs): if self._is_request_to_detail_endpoint() and self.queryset_detail is not None: return self.queryset_detail.all() # todo: test all() else: return super().get_queryset(*args, **kwargs) def _is_request_to_detail_endpoint(self): if hasattr(self, 'lookup_url_kwarg'): lookup = self.lookup_url_kwarg or self.lookup_field return lookup and lookup in self.kwargs class PaginateByMaxMixin: def get_page_size(self, request): if self.page_size_query_param and self.max_page_size and request.query_params.get(self.page_size_query_param) == 'max': return self.max_page_size return super().get_page_size(request) # class ReadOnlyCacheResponseAndETAGMixin(ReadOnlyETAGMixin, CacheResponseMixin): # pass # class CacheResponseAndETAGMixin(ETAGMixin, CacheResponseMixin): # pass class NestedViewSetMixin: def get_queryset(self): return self.filter_queryset_by_parents_lookups( super().get_queryset() ) def filter_queryset_by_parents_lookups(self, queryset): parents_query_dict = self.get_parents_query_dict() if parents_query_dict: try: return queryset.filter(**parents_query_dict) except ValueError: raise Http404 else: return queryset def get_parents_query_dict(self): result = {} for kwarg_name, kwarg_value in self.kwargs.items(): if kwarg_name.startswith(extensions_api_settings.DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX): query_lookup = kwarg_name.replace( extensions_api_settings.DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX, '', 1 ) query_value = kwarg_value result[query_lookup] = query_value return result drf-extensions-0.6.0/rest_framework_extensions/permissions.py000066400000000000000000000013621361353156700247370ustar00rootroot00000000000000from rest_framework.permissions import DjangoObjectPermissions class ExtendedDjangoObjectPermissions(DjangoObjectPermissions): hide_forbidden_for_read_objects = True def has_object_permission(self, request, view, obj): if self.hide_forbidden_for_read_objects: return super().has_object_permission(request, view, obj) else: model_cls = getattr(view, 'model', None) queryset = getattr(view, 'queryset', None) if model_cls is None and queryset is not None: model_cls = queryset.model perms = self.get_required_object_permissions( request.method, model_cls) user = request.user return user.has_perms(perms, obj) drf-extensions-0.6.0/rest_framework_extensions/routers.py000066400000000000000000000046431361353156700240740ustar00rootroot00000000000000from rest_framework.routers import DefaultRouter, SimpleRouter from rest_framework_extensions.utils import compose_parent_pk_kwarg_name class NestedRegistryItem: def __init__(self, router, parent_prefix, parent_item=None, parent_viewset=None): self.router = router self.parent_prefix = parent_prefix self.parent_item = parent_item self.parent_viewset = parent_viewset def register(self, prefix, viewset, basename, parents_query_lookups): self.router._register( prefix=self.get_prefix( current_prefix=prefix, parents_query_lookups=parents_query_lookups), viewset=viewset, basename=basename, ) return NestedRegistryItem( router=self.router, parent_prefix=prefix, parent_item=self, parent_viewset=viewset ) def get_prefix(self, current_prefix, parents_query_lookups): return '{0}/{1}'.format( self.get_parent_prefix(parents_query_lookups), current_prefix ) def get_parent_prefix(self, parents_query_lookups): prefix = '/' current_item = self i = len(parents_query_lookups) - 1 while current_item: parent_lookup_value_regex = getattr( current_item.parent_viewset, 'lookup_value_regex', '[^/.]+') prefix = '{parent_prefix}/(?P<{parent_pk_kwarg_name}>{parent_lookup_value_regex})/{prefix}'.format( parent_prefix=current_item.parent_prefix, parent_pk_kwarg_name=compose_parent_pk_kwarg_name( parents_query_lookups[i]), parent_lookup_value_regex=parent_lookup_value_regex, prefix=prefix ) i -= 1 current_item = current_item.parent_item return prefix.strip('/') class NestedRouterMixin: def _register(self, *args, **kwargs): return super().register(*args, **kwargs) def register(self, *args, **kwargs): self._register(*args, **kwargs) return NestedRegistryItem( router=self, parent_prefix=self.registry[-1][0], parent_viewset=self.registry[-1][1] ) class ExtendedRouterMixin(NestedRouterMixin): pass class ExtendedSimpleRouter(ExtendedRouterMixin, SimpleRouter): pass class ExtendedDefaultRouter(ExtendedRouterMixin, DefaultRouter): pass drf-extensions-0.6.0/rest_framework_extensions/serializers.py000066400000000000000000000033561361353156700247250ustar00rootroot00000000000000from rest_framework_extensions.utils import get_model_opts_concrete_fields def get_fields_for_partial_update(opts, init_data, fields, init_files=None): opts = opts.model._meta.concrete_model._meta partial_fields = list((init_data or {}).keys()) + \ list((init_files or {}).keys()) concrete_field_names = [] for field in get_model_opts_concrete_fields(opts): if not field.primary_key: concrete_field_names.append(field.name) if field.name != field.attname: concrete_field_names.append(field.attname) update_fields = [] for field_name in partial_fields: if field_name in fields: model_field_name = getattr( fields[field_name], 'source') or field_name if model_field_name in concrete_field_names: update_fields.append(model_field_name) return update_fields class PartialUpdateSerializerMixin: def save(self, **kwargs): self._update_fields = kwargs.get('update_fields', None) return super().save(**kwargs) def update(self, instance, validated_attrs): for attr, value in validated_attrs.items(): if hasattr(getattr(instance, attr, None), 'set'): getattr(instance, attr).set(value) else: setattr(instance, attr, value) if self.partial and isinstance(instance, self.Meta.model): instance.save( update_fields=getattr(self, '_update_fields') or get_fields_for_partial_update( opts=self.Meta, init_data=self.get_initial(), fields=self.fields.fields ) ) else: instance.save() return instance drf-extensions-0.6.0/rest_framework_extensions/settings.py000066400000000000000000000031551361353156700242260ustar00rootroot00000000000000from django.conf import settings from rest_framework.settings import APISettings USER_SETTINGS = getattr(settings, 'REST_FRAMEWORK_EXTENSIONS', None) DEFAULTS = { # caching 'DEFAULT_USE_CACHE': 'default', 'DEFAULT_CACHE_RESPONSE_TIMEOUT': None, 'DEFAULT_CACHE_ERRORS': True, 'DEFAULT_CACHE_KEY_FUNC': 'rest_framework_extensions.utils.default_cache_key_func', 'DEFAULT_OBJECT_CACHE_KEY_FUNC': 'rest_framework_extensions.utils.default_object_cache_key_func', 'DEFAULT_LIST_CACHE_KEY_FUNC': 'rest_framework_extensions.utils.default_list_cache_key_func', # ETAG 'DEFAULT_ETAG_FUNC': 'rest_framework_extensions.utils.default_etag_func', 'DEFAULT_OBJECT_ETAG_FUNC': 'rest_framework_extensions.utils.default_object_etag_func', 'DEFAULT_LIST_ETAG_FUNC': 'rest_framework_extensions.utils.default_list_etag_func', # API - ETAG 'DEFAULT_API_OBJECT_ETAG_FUNC': 'rest_framework_extensions.utils.default_api_object_etag_func', 'DEFAULT_API_LIST_ETAG_FUNC': 'rest_framework_extensions.utils.default_api_list_etag_func', # other 'DEFAULT_KEY_CONSTRUCTOR_MEMOIZE_FOR_REQUEST': False, 'DEFAULT_BULK_OPERATION_HEADER_NAME': 'X-BULK-OPERATION', 'DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX': 'parent_lookup_' } IMPORT_STRINGS = [ 'DEFAULT_CACHE_KEY_FUNC', 'DEFAULT_OBJECT_CACHE_KEY_FUNC', 'DEFAULT_LIST_CACHE_KEY_FUNC', 'DEFAULT_ETAG_FUNC', 'DEFAULT_OBJECT_ETAG_FUNC', 'DEFAULT_LIST_ETAG_FUNC', # API - ETAG 'DEFAULT_API_OBJECT_ETAG_FUNC', 'DEFAULT_API_LIST_ETAG_FUNC', ] extensions_api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS) drf-extensions-0.6.0/rest_framework_extensions/test.py000066400000000000000000000141561361353156700233500ustar00rootroot00000000000000# Note that we import as `DjangoRequestFactory` and `DjangoClient` in order # to make it harder for the user to import the wrong thing without realizing. import django from django.conf import settings from django.test.client import Client as DjangoClient from django.test.client import ClientHandler from django.test import testcases from django.utils.http import urlencode from rest_framework.settings import api_settings from django.test.client import RequestFactory # changed here from django.utils.encoding import force_bytes # changed here def force_authenticate(request, user=None, token=None): request._force_auth_user = user request._force_auth_token = token class APIRequestFactory(RequestFactory): renderer_classes_list = api_settings.TEST_REQUEST_RENDERER_CLASSES default_format = api_settings.TEST_REQUEST_DEFAULT_FORMAT def __init__(self, enforce_csrf_checks=False, **defaults): self.enforce_csrf_checks = enforce_csrf_checks self.renderer_classes = {} for cls in self.renderer_classes_list: self.renderer_classes[cls.format] = cls super().__init__(**defaults) def _encode_data(self, data, format=None, content_type=None): """ Encode the data returning a two tuple of (bytes, content_type) """ if not data: return ('', None) assert format is None or content_type is None, ( 'You may not set both `format` and `content_type`.' ) if content_type: # Content type specified explicitly, treat data as a raw bytestring ret = force_bytes(data, settings.DEFAULT_CHARSET) else: format = format or self.default_format assert format in self.renderer_classes, ( "Invalid format '{0}'." "Available formats are {1}. Set TEST_REQUEST_RENDERER_CLASSES " "to enable extra request formats.".format( format, ', '.join( ["'" + fmt + "'" for fmt in self.renderer_classes.keys()]) ) ) # Use format and render the data into a bytestring renderer = self.renderer_classes[format]() ret = renderer.render(data) # Determine the content-type header from the renderer content_type = "{0}; charset={1}".format( renderer.media_type, renderer.charset ) # Coerce text to bytes if required. if isinstance(ret, str): ret = bytes(ret.encode(renderer.charset)) return ret, content_type def get(self, path, data=None, **extra): r = { 'QUERY_STRING': urlencode(data or {}, doseq=True), } # Fix to support old behavior where you have the arguments in the url # See #1461 if not data and '?' in path: r['QUERY_STRING'] = path.split('?')[1] r.update(extra) return self.generic('GET', path, **r) def post(self, path, data=None, format=None, content_type=None, **extra): data, content_type = self._encode_data(data, format, content_type) return self.generic('POST', path, data, content_type, **extra) def put(self, path, data=None, format=None, content_type=None, **extra): data, content_type = self._encode_data(data, format, content_type) return self.generic('PUT', path, data, content_type, **extra) def patch(self, path, data=None, format=None, content_type=None, **extra): data, content_type = self._encode_data(data, format, content_type) return self.generic('PATCH', path, data, content_type, **extra) def delete(self, path, data=None, format=None, content_type=None, **extra): data, content_type = self._encode_data(data, format, content_type) return self.generic('DELETE', path, data, content_type, **extra) def options(self, path, data=None, format=None, content_type=None, **extra): data, content_type = self._encode_data(data, format, content_type) return self.generic('OPTIONS', path, data, content_type, **extra) def request(self, **kwargs): request = super().request(**kwargs) request._dont_enforce_csrf_checks = not self.enforce_csrf_checks return request class ForceAuthClientHandler(ClientHandler): """ A patched version of ClientHandler that can enforce authentication on the outgoing requests. """ def __init__(self, *args, **kwargs): self._force_user = None self._force_token = None super().__init__(*args, **kwargs) def get_response(self, request): # This is the simplest place we can hook into to patch the # request object. force_authenticate(request, self._force_user, self._force_token) return super().get_response(request) class APIClient(APIRequestFactory, DjangoClient): def __init__(self, enforce_csrf_checks=False, **defaults): super().__init__(**defaults) self.handler = ForceAuthClientHandler(enforce_csrf_checks) self._credentials = {} def credentials(self, **kwargs): """ Sets headers that will be used on every outgoing request. """ self._credentials = kwargs def force_authenticate(self, user=None, token=None): """ Forcibly authenticates outgoing requests with the given user and/or token. """ self.handler._force_user = user self.handler._force_token = token if user is None: self.logout() # Also clear any possible session info if required def request(self, **kwargs): # Ensure that any credentials set get added to every request. kwargs.update(self._credentials) return super().request(**kwargs) class APITransactionTestCase(testcases.TransactionTestCase): client_class = APIClient class APITestCase(testcases.TestCase): client_class = APIClient if django.VERSION >= (1, 4): class APISimpleTestCase(testcases.SimpleTestCase): client_class = APIClient class APILiveServerTestCase(testcases.LiveServerTestCase): client_class = APIClient drf-extensions-0.6.0/rest_framework_extensions/utils.py000066400000000000000000000037151361353156700235300ustar00rootroot00000000000000import itertools from distutils.version import LooseVersion import rest_framework from rest_framework_extensions.key_constructor.constructors import ( DefaultKeyConstructor, DefaultObjectKeyConstructor, DefaultListKeyConstructor, DefaultAPIModelInstanceKeyConstructor, DefaultAPIModelListKeyConstructor ) from rest_framework_extensions.settings import extensions_api_settings def get_rest_framework_version(): return tuple(LooseVersion(rest_framework.VERSION).version) def flatten(list_of_lists): """ Takes an iterable of iterables, returns a single iterable containing all items """ # todo: test me return itertools.chain(*list_of_lists) def prepare_header_name(name): """ >> prepare_header_name('Accept-Language') http_accept_language """ return 'http_{0}'.format(name.strip().replace('-', '_')).upper() def get_unique_method_id(view_instance, view_method): # todo: test me as UniqueMethodIdKeyBit return '.'.join([ view_instance.__module__, view_instance.__class__.__name__, view_method.__name__ ]) def get_model_opts_concrete_fields(opts): # todo: test me if not hasattr(opts, 'concrete_fields'): opts.concrete_fields = [f for f in opts.fields if f.column is not None] return opts.concrete_fields def compose_parent_pk_kwarg_name(value): return '{0}{1}'.format( extensions_api_settings.DEFAULT_PARENT_LOOKUP_KWARG_NAME_PREFIX, value ) default_cache_key_func = DefaultKeyConstructor() default_object_cache_key_func = DefaultObjectKeyConstructor() default_list_cache_key_func = DefaultListKeyConstructor() default_etag_func = default_cache_key_func default_object_etag_func = default_object_cache_key_func default_list_etag_func = default_list_cache_key_func # API (object-centered) functions default_api_object_etag_func = DefaultAPIModelInstanceKeyConstructor() default_api_list_etag_func = DefaultAPIModelListKeyConstructor() drf-extensions-0.6.0/setup.cfg000066400000000000000000000000341361353156700163150ustar00rootroot00000000000000[bdist_wheel] universal = 1 drf-extensions-0.6.0/setup.py000066400000000000000000000050071361353156700162130ustar00rootroot00000000000000#!/usr/bin/env python from setuptools import setup import re import os import sys def get_version(package): """ Return package version as listed in `__version__` in `init.py`. """ init_py = open(os.path.join(package, '__init__.py')).read() return re.match("__version__ = ['\"]([^'\"]+)['\"]", init_py).group(1) def get_packages(package): """ Return root package and all sub-packages. """ return [dirpath for dirpath, dirnames, filenames in os.walk(package) if os.path.exists(os.path.join(dirpath, '__init__.py'))] def get_package_data(package): """ Return all files under the root package, that are not in a package themselves. """ walk = [(dirpath.replace(package + os.sep, '', 1), filenames) for dirpath, dirnames, filenames in os.walk(package) if not os.path.exists(os.path.join(dirpath, '__init__.py'))] filepaths = [] for base, filenames in walk: filepaths.extend([os.path.join(base, filename) for filename in filenames]) return {package: filepaths} version = get_version('rest_framework_extensions') if sys.argv[-1] == 'publish': os.system("python setup.py sdist upload") os.system("python setup.py bdist_wheel upload") print("You probably want to also tag the version now:") print(" git tag -a %s -m 'version %s'" % (version, version)) print(" git push --tags") sys.exit() setup( name='drf-extensions', version=version, url='http://github.com/chibisov/drf-extensions', download_url='https://pypi.python.org/pypi/drf-extensions/', license='BSD', install_requires=['djangorestframework>=3.9.3'], description='Extensions for Django REST Framework', long_description='DRF-extensions is a collection of custom extensions for Django REST Framework', author='Asif Saif Uddin, Gennady Chibisov', author_email='auvipy@gmail.com', packages=get_packages('rest_framework_extensions'), package_data=get_package_data('rest_framework_extensions'), test_suite='rest_framework_extensions.runtests.runtests.main', classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Framework :: Django', 'Intended Audience :: Developers', 'License :: OSI Approved :: MIT License', 'Operating System :: OS Independent', 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Topic :: Internet :: WWW/HTTP', ] ) drf-extensions-0.6.0/tests_app/000077500000000000000000000000001361353156700165015ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/__init__.py000066400000000000000000000000001361353156700206000ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/plugins.py000066400000000000000000000040321361353156700205330ustar00rootroot00000000000000import shutil import os from django_nose.plugin import AlwaysOnPlugin from django.test import TestCase from django.core.cache import cache from django.conf import settings class UnitTestDiscoveryPlugin(AlwaysOnPlugin): """ Enables unittest compatibility mode (dont test functions, only TestCase subclasses, and only methods that start with [Tt]est). """ enabled = True def wantModule(self, module): return True def wantFile(self, file): if file.endswith('.py'): return True def wantClass(self, cls): if not issubclass(cls, TestCase): return False def wantMethod(self, method): if not method.__name__.lower().startswith('test'): return False def wantFunction(self, function): return False class PrepareRestFrameworkSettingsPlugin(AlwaysOnPlugin): def begin(self): self._monkeypatch_default_settings() def _monkeypatch_default_settings(self): from rest_framework import settings PATCH_REST_FRAMEWORK = { # Testing 'TEST_REQUEST_RENDERER_CLASSES': ( 'rest_framework.renderers.MultiPartRenderer', 'rest_framework.renderers.JSONRenderer' ), 'TEST_REQUEST_DEFAULT_FORMAT': 'multipart', } for key, value in PATCH_REST_FRAMEWORK.items(): if key not in settings.DEFAULTS: settings.DEFAULTS[key] = value class PrepareFileStorageDir(AlwaysOnPlugin): def begin(self): if not os.path.isdir(settings.MEDIA_ROOT): os.makedirs(settings.MEDIA_ROOT) def finalize(self, result): shutil.rmtree(settings.MEDIA_ROOT, ignore_errors=True) class FlushCache(AlwaysOnPlugin): # startTest didn't work :( def begin(self): self._monkeypatch_testcase() def _monkeypatch_testcase(self): old_run = TestCase.run def new_run(*args, **kwargs): cache.clear() return old_run(*args, **kwargs) TestCase.run = new_run drf-extensions-0.6.0/tests_app/requirements.txt000066400000000000000000000000571361353156700217670ustar00rootroot00000000000000nose django-nose django-filter>=2.1.0 mock ipdbdrf-extensions-0.6.0/tests_app/settings.py000066400000000000000000000110541361353156700207140ustar00rootroot00000000000000# Django settings for testproject project. import multiprocessing DEBUG = True DEBUG_PROPAGATE_EXCEPTIONS = True ALLOWED_HOSTS = ['*'] ADMINS = ( # ('Your Name', 'your_email@domain.com'), ) MANAGERS = ADMINS DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': 'drf_extensions', 'TEST_CHARSET': 'utf8', }, } CACHES = { 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', }, 'special_cache': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', }, 'another_special_cache': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', }, } # Local time zone for this installation. Choices can be found here: # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name # although not all choices may be available on all operating systems. # On Unix systems, a value of None will cause Django to use the same # timezone as the operating system. # If running in a Windows environment this must be set to the same as your # system time zone. TIME_ZONE = 'Europe/London' # Language code for this installation. All choices can be found here: # http://www.i18nguy.com/unicode/language-identifiers.html LANGUAGE_CODE = 'en-uk' SITE_ID = 1 # If you set this to False, Django will make some optimizations so as not # to load the internationalization machinery. USE_I18N = True # If you set this to False, Django will not format dates, numbers and # calendars according to the current locale USE_L10N = True # Absolute filesystem path to the directory that will hold user-uploaded files. # Example: "/home/media/media.lawrence.com/" MEDIA_ROOT = 'tests_app/tests/files' # URL that handles the media served from MEDIA_ROOT. Make sure to use a # trailing slash if there is a path component (optional in other cases). # Examples: "http://media.lawrence.com", "http://example.com/media/" MEDIA_URL = '' # Make this unique, and don't share it with anybody. SECRET_KEY = 'u@x-aj9(hoh#rb-^ymf#g2jx_hp0vj7u5#b@ag1n^seu9e!%cy' # List of callables that know how to import templates from various sources. TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', 'DIRS': [], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ 'django.template.context_processors.debug', 'django.template.context_processors.request', 'django.contrib.auth.context_processors.auth', 'django.contrib.messages.context_processors.messages', ], }, }, ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] ROOT_URLCONF = 'urls' INSTALLED_APPS = ( 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.sites', 'django.contrib.messages', # Uncomment the next line to enable the admin: # 'django.contrib.admin', # Uncomment the next line to enable admin documentation: # 'django.contrib.admindocs', 'django_nose', 'guardian', 'rest_framework_extensions', 'tests_app.tests.functional', 'tests_app.tests.unit', ) STATIC_URL = '/static/' # Password validation # https://docs.djangoproject.com/en/{{ docs_version }}/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', }, { 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', }, { 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', }, { 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, ] AUTH_USER_MODEL = 'auth.User' TEST_RUNNER = 'django_nose.NoseTestSuiteRunner' NOSE_ARGS = [ '--processes=%s' % multiprocessing.cpu_count(), '--process-timeout=100', '--nocapture', ] NOSE_PLUGINS = [ 'plugins.UnitTestDiscoveryPlugin', 'plugins.PrepareRestFrameworkSettingsPlugin', 'plugins.FlushCache', 'plugins.PrepareFileStorageDir' ] # guardian ANONYMOUS_USER_ID = -1 AUTHENTICATION_BACKENDS = ( 'django.contrib.auth.backends.ModelBackend', # this is default 'guardian.backends.ObjectPermissionBackend', ) drf-extensions-0.6.0/tests_app/tests/000077500000000000000000000000001361353156700176435ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/__init__.py000066400000000000000000000000001361353156700217420ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/000077500000000000000000000000001361353156700220055ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/__init__.py000066400000000000000000000000001361353156700241040ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/_concurrency/000077500000000000000000000000001361353156700244765ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/_concurrency/__init__.py000066400000000000000000000000001361353156700265750ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/_concurrency/conditional_request/000077500000000000000000000000001361353156700305515ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/_concurrency/conditional_request/__init__.py000066400000000000000000000000001361353156700326500ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/_concurrency/conditional_request/models.py000066400000000000000000000005421361353156700324070ustar00rootroot00000000000000from django.db import models class Book(models.Model): """A sample model for conditional requests.""" name = models.CharField(max_length=100, default=None, blank=True, null=True) author = models.CharField(max_length=100, default=None, blank=True, null=True) issn = models.CharField(max_length=100, default=None, blank=True, null=True) drf-extensions-0.6.0/tests_app/tests/functional/_concurrency/conditional_request/serializers.py000066400000000000000000000002651361353156700334620ustar00rootroot00000000000000from rest_framework import serializers from .models import Book class BookSerializer(serializers.ModelSerializer): class Meta: model = Book fields = '__all__' drf-extensions-0.6.0/tests_app/tests/functional/_concurrency/conditional_request/tests.py000066400000000000000000000444101361353156700322700ustar00rootroot00000000000000from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase from .models import Book from django.test import override_settings import json @override_settings(ROOT_URLCONF='tests_app.tests.functional.concurrency.conditional_request.urls') class BookAPITestCases(APITestCase): """ Run the conditional requests test cases. `tox -- tests_app.tests.functional.concurrency.conditional_request.tests` """ def setUp(self): # create a book self.book = Book.objects.create(name='The Summons', author='Stephen King', issn='9780345531988') def alter_book_issn(self, issn='0123456789012'): """Mimic alteration of object in the DB.""" self.book.issn = issn self.book.save() return self.book def test_book_retrieve_cache_hit(self): """Test idempotent retrieve using 'If-None-Match' HTTP header, should result in HTTP 304.""" book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) # memorize the ETag from the response to send with the next request etag = book_response['ETag'] # issue the same request again book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json', HTTP_IF_NONE_MATCH=etag) self.assertEqual(book_response.status_code, status.HTTP_304_NOT_MODIFIED, 'The response status code must be 304!') self.assertEqual(book_response['ETag'], etag) def test_book_retrieve_cache_miss(self): """Test idempotent retrieve using 'If-None-Match' HTTP header, should result in HTTP 200.""" book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) # memorize the ETag from the response to send with the next request etag = book_response['ETag'] # simulate background activity on the book self.alter_book_issn() # issue the same request again book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json', HTTP_IF_NONE_MATCH=etag) self.assertEqual(book_response.status_code, status.HTTP_200_OK, 'The response status code must be 200!') self.assertNotEqual(book_response['ETag'], etag) def test_book_update_unconditional(self): """Test an update without providing the 'ETag' HTTP header, should yield HTTP 200.""" book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) book_json = json.loads(book_response.content.decode()) # alter the author book_json['author'] = 'John Grisham' url = reverse('book_view-unconditional_update', kwargs={'pk': book_json['id']}) response = self.client.put(url, data=book_json) self.assertEqual(response.status_code, status.HTTP_200_OK, 'The response status code must be 200!') updated_book_json = json.loads(response.content.decode()) self.assertEqual(updated_book_json['author'], book_json['author'], 'Author must be John Grisham!') def test_book_delete_unconditional(self): """Test delete, should result in HTTP 204.""" # retrieve book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) # delete response = self.client.delete(reverse('book_view-unconditional_delete', kwargs={'pk': self.book.id})) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT, 'The response status code must be 204!') def test_book_conditional_update(self): """Test a conditional update of a book using 'If-Match' HTTP header, should yield HTTP 200.""" book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) self.assertIsNotNone(book_response['ETag']) book_json = json.loads(book_response.content.decode()) # memorize the ETag from the response to send with the next request etag = book_response['ETag'] # alter the author book_json['author'] = 'John Grisham' url = reverse('book-detail', kwargs={'pk': book_json['id']}) response = self.client.put(url, data=book_json, HTTP_IF_MATCH=etag) # <-- set the ETag header to trigger the conditional request # ######################## ######################## ######################## # this update must succeed, since the if-match header is the same as the ETag sent from the server! self.assertNotEqual(response.status_code, status.HTTP_412_PRECONDITION_FAILED, 'The response status code must ' 'not be 412!') self.assertEqual(response.status_code, status.HTTP_200_OK, 'The response status code must be 200!') # ######################## ######################## ######################## updated_book_json = json.loads(response.content.decode()) self.assertEqual(updated_book_json['author'], book_json['author'], 'Author must be John Grisham!') def test_book_conditional_update_fail(self): """Test a conditional update of a book using 'If-Match' HTTP header, should yield HTTP 412.""" book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) self.assertIsNotNone(book_response['ETag']) book_json = json.loads(book_response.content.decode()) # memorize the ETag from the response to send with the next request etag = book_response['ETag'] # mimic background activity self.alter_book_issn() # alter the author book_json['author'] = 'John Grisham' url = reverse('book-detail', kwargs={'pk': book_json['id']}) response = self.client.put(url, data=book_json, HTTP_IF_MATCH=etag) # <-- set the ETag header to trigger the conditional request self.assertEqual(response.status_code, status.HTTP_412_PRECONDITION_FAILED, 'The response status code must ' 'be 412!') def test_book_conditional_update_fail_no_if_match(self): """Test a conditional update of a book using no HTTP 'If-Match' header, should yield HTTP 428.""" book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) self.assertIsNotNone(book_response['ETag']) book_json = json.loads(book_response.content.decode()) # alter the author book_json['author'] = 'John Grisham' url = reverse('book-detail', kwargs={'pk': book_json['id']}) response = self.client.put(url, data=book_json) self.assertEqual(response.status_code, status.HTTP_428_PRECONDITION_REQUIRED, 'The response status code must ' 'be 428!') def test_book_conditional_update_fail_first_then_succeed(self): """Test a conditional update of a book using 'If-Match' HTTP header, should yield HTTP 412, then 200.""" book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) self.assertIsNotNone(book_response['ETag']) book_json = json.loads(book_response.content.decode()) # memorize the ETag from the response to send with the next request etag = book_response['ETag'] # mimic background activity self.alter_book_issn() # try to alter the author book_json['author'] = 'John Grisham' url = reverse('book-detail', kwargs={'pk': book_json['id']}) response = self.client.put(url, data=book_json, HTTP_IF_MATCH=etag) # <-- set the ETag header to trigger the conditional request # ######################## ######################## ######################## # this update must succeed, since the if-match header is the same as the ETag sent from the server! self.assertEqual(response.status_code, status.HTTP_412_PRECONDITION_FAILED, 'The response status code must ' 'be 412!') # ######################## ######################## ######################## # fetch the instance again and try again book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) self.assertIsNotNone(book_response['ETag']) book_json = json.loads(book_response.content.decode()) # memorize the ETag from the response to send with the next request new_etag = book_response['ETag'] # ...client merges/rejects the local changes... # try again to update response = self.client.put(url, data=book_json, HTTP_IF_MATCH=new_etag) # <-- set the ETag header to trigger the conditional request # ######################## ######################## ######################## # this update must succeed, since the if-match header is the same as the ETag sent from the server! self.assertNotEqual(response.status_code, status.HTTP_412_PRECONDITION_FAILED, 'The response status code must ' 'not be 412!') self.assertEqual(response.status_code, status.HTTP_200_OK, 'The response status code must be 200!') # ######################## ######################## ######################## updated_book_json = json.loads(response.content.decode()) self.assertEqual(updated_book_json['author'], book_json['author'], 'Author must be John Grisham!') def test_book_conditional_delete_default_viewset(self): """Test conditional delete using 'If-Match' HTTP header, should result in HTTP 204.""" book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) # memorize the ETag from the response to send with the next request etag = book_response['ETag'] # delete the instance book_response = self.client.delete(reverse('book-detail', kwargs={'pk': self.book.id}), HTTP_IF_MATCH=etag) self.assertEqual(book_response.status_code, status.HTTP_204_NO_CONTENT, 'The response status code must be 204!') def test_book_conditional_delete_custom_view(self): """Test conditional delete using 'If-Match' HTTP header, should result in HTTP 204.""" book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) # memorize the ETag from the response to send with the next request etag = book_response['ETag'] # delete the instance book_response = self.client.delete(reverse('book_view-custom_delete', kwargs={'pk': self.book.id}), HTTP_IF_MATCH=etag) self.assertEqual(book_response.status_code, status.HTTP_204_NO_CONTENT, 'The response status code must be 204!') def test_book_conditional_delete_fail(self): """Test conditional delete using 'If-Match' HTTP header, should result in HTTP 412.""" book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) # memorize the ETag from the response to send with the next request etag = book_response['ETag'] # alter the book self.alter_book_issn() # delete the instance book_response = self.client.delete(reverse('book-detail', kwargs={'pk': self.book.id}), HTTP_IF_MATCH=etag) self.assertEqual(book_response.status_code, status.HTTP_412_PRECONDITION_FAILED, 'The response status code must be 412!') def test_book_conditional_delete_fail_no_if_match(self): """Test conditional delete without 'If-Match' HTTP header, should result in HTTP 428.""" book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) # delete the instance book_response = self.client.delete(reverse('book-detail', kwargs={'pk': self.book.id})) self.assertEqual(book_response.status_code, status.HTTP_428_PRECONDITION_REQUIRED, 'The response status code must be 428!') def test_book_retrieve_cache_hit_view(self): """Test idempotent retrieve using 'If-None-Match' HTTP header, should result in HTTP 304.""" book_response = self.client.get(reverse('book_view-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) # memorize the ETag from the response to send with the next request etag = book_response['ETag'] # issue the same request again book_response = self.client.get(reverse('book_view-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json', HTTP_IF_NONE_MATCH=etag) self.assertEqual(book_response.status_code, status.HTTP_304_NOT_MODIFIED, 'The response status code must be 304!') self.assertEqual(book_response['ETag'], etag) def test_book_conditional_custom_delete_decorator(self): """Test conditional delete using 'If-Match' HTTP header, should result in HTTP 204.""" book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) # memorize the ETag from the response to send with the next request etag = book_response['ETag'] # delete the instance book_response = self.client.delete(reverse('book_view-custom_delete', kwargs={'pk': self.book.id}), HTTP_IF_MATCH=etag) self.assertEqual(book_response.status_code, status.HTTP_204_NO_CONTENT, 'The response status code must be 204!') def test_book_conditional_custom_delete_decorator_fail(self): """Test conditional delete using 'If-Match' HTTP header, should result in HTTP 412.""" book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) # memorize the ETag from the response to send with the next request etag = book_response['ETag'] # change the instance in the DB self.alter_book_issn() # delete the instance book_response = self.client.delete(reverse('book_view-custom_delete', kwargs={'pk': self.book.id}), HTTP_IF_MATCH=etag) self.assertEqual(book_response.status_code, status.HTTP_412_PRECONDITION_FAILED, 'The response status code must be 412!') def test_book_conditional_custom_delete_decorator_fail__not_found(self): """Test conditional delete using 'If-Match' HTTP header, should result in HTTP 412.""" book_response = self.client.get(reverse('book-detail', kwargs={'pk': self.book.id}), CONTENT_TYPE='application/json') self.assertEqual(book_response.status_code, status.HTTP_200_OK) book_json = json.loads(book_response.content.decode()) # memorize the ETag from the response to send with the next request etag = book_response['ETag'] # delete the instance in the DB self.book.delete() # delete the instance book_response = self.client.delete(reverse('book_view-custom_delete', kwargs={'pk': book_json['id']}), HTTP_IF_MATCH=etag) self.assertEqual(book_response.status_code, status.HTTP_412_PRECONDITION_FAILED, 'The response status code must be 412!') drf-extensions-0.6.0/tests_app/tests/functional/_concurrency/conditional_request/urls.py000066400000000000000000000020441361353156700321100ustar00rootroot00000000000000from django.conf.urls import url, include from rest_framework import routers from .views import (BookViewSet, BookListCreateView, BookChangeView, BookCustomDestroyView, BookUnconditionalDestroyView, BookUnconditionalUpdateView) router = routers.DefaultRouter() router.register(r'books', BookViewSet) urlpatterns = [ # manually add endpoints for APIView instances url(r'books_view/(?P[0-9]+)/custom/delete/', BookCustomDestroyView.as_view(), name='book_view-custom_delete'), url(r'books_view/(?P[0-9]+)/unconditional/delete/', BookUnconditionalDestroyView.as_view(), name='book_view-unconditional_delete'), url(r'books_view/(?P[0-9]+)/unconditional/update/', BookUnconditionalUpdateView.as_view(), name='book_view-unconditional_update'), url(r'books_view/', BookListCreateView.as_view(), name='book_view-list'), url(r'books_view/(?P[0-9]+)/', BookChangeView.as_view(), name='book_view-detail'), # include the URLs from the default viewset url(r'^', include(router.urls)), ] drf-extensions-0.6.0/tests_app/tests/functional/_concurrency/conditional_request/views.py000066400000000000000000000046011361353156700322610ustar00rootroot00000000000000from rest_framework import viewsets from rest_framework import generics from rest_framework import status from rest_framework.response import Response from rest_framework_extensions.etag.mixins import APIETAGMixin from rest_framework_extensions.etag.decorators import api_etag from rest_framework_extensions.utils import default_api_object_etag_func from .models import Book from .serializers import BookSerializer class BookViewSet(APIETAGMixin, viewsets.ModelViewSet): """Test the mixin with DRF viewset.""" queryset = Book.objects.all() serializer_class = BookSerializer class BookChangeView(APIETAGMixin, generics.RetrieveUpdateDestroyAPIView): """Test the mixin with DRF generic API views.""" queryset = Book.objects.all() serializer_class = BookSerializer class BookListCreateView(APIETAGMixin, generics.ListCreateAPIView): """Test the mixin with DRF generic API views.""" queryset = Book.objects.all() serializer_class = BookSerializer class BookCustomDestroyView(generics.DestroyAPIView): """Test the decorator with DRF generic API views.""" # include the queryset here to enable the object lookup in `@api_etag` queryset = Book.objects.all() @api_etag(etag_func=default_api_object_etag_func) def delete(self, request, *args, **kwargs): obj = Book.objects.get(id=kwargs['pk']) obj.delete() return Response(status=status.HTTP_204_NO_CONTENT) class BookUnconditionalDestroyView(generics.DestroyAPIView): """Test the decorator with DRF generic API views.""" # include the queryset here to enable the object lookup in `@api_etag` queryset = Book.objects.all() @api_etag(etag_func=default_api_object_etag_func, precondition_map={}) def delete(self, request, *args, **kwargs): obj = Book.objects.get(id=kwargs['pk']) obj.delete() return Response(status=status.HTTP_204_NO_CONTENT) class BookUnconditionalUpdateView(generics.UpdateAPIView): """Test the decorator with DRF generic API views.""" # include the queryset here to enable the object lookup in `@api_etag` queryset = Book.objects.all() serializer_class = BookSerializer @api_etag(etag_func=default_api_object_etag_func, precondition_map={}) def update(self, request, *args, **kwargs): return super().update(request, *args, **kwargs)drf-extensions-0.6.0/tests_app/tests/functional/_examples/000077500000000000000000000000001361353156700237625ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/_examples/__init__.py000066400000000000000000000000011361353156700260620ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/functional/_examples/etags/000077500000000000000000000000001361353156700250655ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/_examples/etags/__init__.py000066400000000000000000000000011361353156700271650ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/functional/_examples/etags/remove_etag_gzip_postfix/000077500000000000000000000000001361353156700321675ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/_examples/etags/remove_etag_gzip_postfix/__init__.py000066400000000000000000000000011361353156700342670ustar00rootroot00000000000000 middleware.py000066400000000000000000000005631361353156700346030ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/_examples/etags/remove_etag_gzip_postfixtry: from django.utils.deprecation import MiddlewareMixin except ImportError: MiddlewareMixin = object class RemoveEtagGzipPostfix(MiddlewareMixin): def process_response(self, request, response): if response.has_header('ETag') and response['ETag'][-6:] == ';gzip"': response['ETag'] = response['ETag'][:-6] + '"' return response drf-extensions-0.6.0/tests_app/tests/functional/_examples/etags/remove_etag_gzip_postfix/tests.py000066400000000000000000000022211361353156700337000ustar00rootroot00000000000000from django.test import TestCase, override_settings @override_settings(ROOT_URLCONF='tests_app.tests.functional.examples.etags.remove_etag_gzip_postfix.urls') class RemoveEtagGzipPostfixTest(TestCase): @override_settings(MIDDLEWARE_CLASSES=( 'django.middleware.gzip.GZipMiddleware', 'django.middleware.common.CommonMiddleware' )) def test_without_middleware(self): response = self.client.get('/remove-etag-gzip-postfix/', **{ 'HTTP_ACCEPT_ENCODING': 'gzip' }) self.assertEqual(response.status_code, 200) self.assertEqual(response['ETag'], '"etag_value;gzip"') @override_settings(MIDDLEWARE_CLASSES=( 'tests_app.tests.functional.examples.etags.remove_etag_gzip_postfix.middleware.RemoveEtagGzipPostfix', 'django.middleware.gzip.GZipMiddleware', 'django.middleware.common.CommonMiddleware' )) def test_with_middleware(self): response = self.client.get('/remove-etag-gzip-postfix/', **{ 'HTTP_ACCEPT_ENCODING': 'gzip' }) self.assertEqual(response.status_code, 200) self.assertEqual(response['ETag'], '"etag_value"') drf-extensions-0.6.0/tests_app/tests/functional/_examples/etags/remove_etag_gzip_postfix/urls.py000066400000000000000000000002131361353156700335220ustar00rootroot00000000000000from django.conf.urls import url from .views import MyView urlpatterns = [ url(r'^remove-etag-gzip-postfix/$', MyView.as_view()), ] drf-extensions-0.6.0/tests_app/tests/functional/_examples/etags/remove_etag_gzip_postfix/views.py000066400000000000000000000010621361353156700336750ustar00rootroot00000000000000from django.views import View from django.http import HttpResponse class MyView(View): def get(self, request): """ GZipMiddleware will NOT compress content if any of the following are true: * The content body is less than 200 bytes long. * The response has already set the Content-Encoding header. * The request (the browser) hasn’t sent an Accept-Encoding header containing gzip. """ response = HttpResponse('r' * 300) response['ETag'] = '"etag_value"' return response drf-extensions-0.6.0/tests_app/tests/functional/cache/000077500000000000000000000000001361353156700230505ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/cache/__init__.py000066400000000000000000000000011361353156700251500ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/functional/cache/decorators/000077500000000000000000000000001361353156700252155ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/cache/decorators/__init__.py000066400000000000000000000000011361353156700273150ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/functional/cache/decorators/tests.py000066400000000000000000000011121361353156700267240ustar00rootroot00000000000000from django.test import TestCase, override_settings from django.utils.encoding import force_str @override_settings(ROOT_URLCONF='tests_app.tests.functional.cache.decorators.urls') class TestCacheResponseFunctionally(TestCase): def test_should_return_response(self): resp = self.client.get('/hello/') self.assertEqual(force_str(resp.content), '"Hello world"') def test_should_return_same_response_if_cached(self): resp_1 = self.client.get('/hello/') resp_2 = self.client.get('/hello/') self.assertEqual(resp_1.content, resp_2.content) drf-extensions-0.6.0/tests_app/tests/functional/cache/decorators/urls.py000066400000000000000000000002141361353156700265510ustar00rootroot00000000000000from django.conf.urls import url from .views import HelloView urlpatterns = [ url(r'^hello/$', HelloView.as_view(), name='hello'), ] drf-extensions-0.6.0/tests_app/tests/functional/cache/decorators/views.py000066400000000000000000000004411361353156700267230ustar00rootroot00000000000000from rest_framework import views from rest_framework.response import Response from rest_framework_extensions.cache.decorators import cache_response class HelloView(views.APIView): @cache_response() def get(self, request, *args, **kwargs): return Response('Hello world') drf-extensions-0.6.0/tests_app/tests/functional/key_constructor/000077500000000000000000000000001361353156700252425ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/key_constructor/__init__.py000066400000000000000000000000011361353156700273420ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/functional/key_constructor/bits/000077500000000000000000000000001361353156700262035ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/key_constructor/bits/__init__.py000066400000000000000000000000011361353156700303030ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/functional/key_constructor/bits/models.py000066400000000000000000000004271361353156700300430ustar00rootroot00000000000000from django.db import models class KeyConstructorUserProperty(models.Model): name = models.CharField(max_length=100) class KeyConstructorUserModel(models.Model): property = models.ForeignKey( KeyConstructorUserProperty, on_delete=models.CASCADE ) drf-extensions-0.6.0/tests_app/tests/functional/key_constructor/bits/serializers.py000066400000000000000000000003411361353156700311070ustar00rootroot00000000000000from rest_framework import serializers from .models import KeyConstructorUserModel class UserModelSerializer(serializers.ModelSerializer): class Meta: model = KeyConstructorUserModel fields = '__all__' drf-extensions-0.6.0/tests_app/tests/functional/key_constructor/bits/tests.py000066400000000000000000000025431361353156700277230ustar00rootroot00000000000000from django.test import override_settings from rest_framework_extensions.test import APITestCase from .models import KeyConstructorUserProperty @override_settings(ROOT_URLCONF='tests_app.tests.functional.key_constructor.bits.urls') class ListSqlQueryKeyBitTestBehaviour(APITestCase): """Regression tests for https://github.com/chibisov/drf-extensions/issues/28#issuecomment-51711927 `rest_framework.filters.DjangoFilterBackend` uses defalut `FilterSet`. When there is no filtered fk in db, then `FilterSet.form` is invalid with errors: {'property': [u'Select a valid choice. That choice is not one of the available choices.']} In that case `FilterSet.qs` returns `self.queryset.none()` """ def test_with_fk_in_db(self): KeyConstructorUserProperty.objects.create(name='some property') # list response = self.client.get('/users/?property=1') self.assertEqual(response.status_code, 200) # retrieve response = self.client.get('/users/1/?property=1') self.assertEqual(response.status_code, 404) def test_without_fk_in_db(self): # list response = self.client.get('/users/?property=1') self.assertEqual(response.status_code, 400) # retrieve response = self.client.get('/users/1/?property=1') self.assertEqual(response.status_code, 400) drf-extensions-0.6.0/tests_app/tests/functional/key_constructor/bits/urls.py000066400000000000000000000003101361353156700275340ustar00rootroot00000000000000from rest_framework import routers from .views import UserModelViewSet viewset_router = routers.DefaultRouter() viewset_router.register('users', UserModelViewSet) urlpatterns = viewset_router.urls drf-extensions-0.6.0/tests_app/tests/functional/key_constructor/bits/views.py000066400000000000000000000006241361353156700277140ustar00rootroot00000000000000import django_filters from rest_framework import viewsets from .models import KeyConstructorUserModel as UserModel from .serializers import UserModelSerializer class UserModelViewSet(viewsets.ModelViewSet): queryset = UserModel.objects.all() serializer_class = UserModelSerializer filter_backends = (django_filters.rest_framework.DjangoFilterBackend,) filterset_fields = ('property',) drf-extensions-0.6.0/tests_app/tests/functional/migrations/000077500000000000000000000000001361353156700241615ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/migrations/0001_initial.py000066400000000000000000000163451361353156700266350ustar00rootroot00000000000000# Generated by Django 2.2 on 2019-04-16 11:51 from django.db import migrations, models import django.db.models.deletion class Migration(migrations.Migration): initial = True dependencies = [ ('contenttypes', '0002_remove_content_type_name'), ] operations = [ migrations.CreateModel( name='Comment', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('email', models.EmailField(max_length=254)), ('content', models.CharField(max_length=200)), ('created', models.DateTimeField(auto_now_add=True)), ], ), migrations.CreateModel( name='CommentForListDestroyModelMixin', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('email', models.EmailField(max_length=254)), ], ), migrations.CreateModel( name='CommentForListUpdateModelMixin', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('email', models.EmailField(max_length=254)), ], ), migrations.CreateModel( name='CommentForPaginateByMaxMixin', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('email', models.EmailField(max_length=254)), ('content', models.CharField(max_length=200)), ('created', models.DateTimeField(auto_now_add=True)), ], options={ 'ordering': ['id'], }, ), migrations.CreateModel( name='DefaultRouterGroupModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=10)), ], ), migrations.CreateModel( name='DefaultRouterPermissionModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=10)), ], ), migrations.CreateModel( name='KeyConstructorUserProperty', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=100)), ], ), migrations.CreateModel( name='NestedRouterMixinBookModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(max_length=30)), ], ), migrations.CreateModel( name='NestedRouterMixinGroupModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=10)), ], ), migrations.CreateModel( name='NestedRouterMixinPermissionModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=10)), ], ), migrations.CreateModel( name='NestedRouterMixinTaskModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(max_length=30)), ], ), migrations.CreateModel( name='PermissionsComment', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('text', models.CharField(max_length=100)), ], ), migrations.CreateModel( name='RouterTestModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('uuid', models.CharField(max_length=20)), ('text', models.CharField(max_length=200)), ], ), migrations.CreateModel( name='UserForListUpdateModelMixin', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('email', models.EmailField(max_length=254)), ('name', models.CharField(max_length=10)), ('age', models.IntegerField()), ('last_name', models.CharField(max_length=10)), ('password', models.CharField(max_length=100)), ], ), migrations.CreateModel( name='NestedRouterMixinUserModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('email', models.EmailField(blank=True, max_length=254, null=True)), ('name', models.CharField(max_length=10)), ('groups', models.ManyToManyField(related_name='user_groups', to='functional.NestedRouterMixinGroupModel')), ], ), migrations.AddField( model_name='nestedroutermixingroupmodel', name='permissions', field=models.ManyToManyField(to='functional.NestedRouterMixinPermissionModel'), ), migrations.CreateModel( name='NestedRouterMixinCommentModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('object_id', models.PositiveIntegerField(blank=True, null=True)), ('text', models.CharField(max_length=30)), ('content_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), ], ), migrations.CreateModel( name='KeyConstructorUserModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='functional.KeyConstructorUserProperty')), ], ), migrations.CreateModel( name='DefaultRouterUserModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=10)), ('groups', models.ManyToManyField(related_name='user_groups', to='functional.DefaultRouterGroupModel')), ], ), migrations.AddField( model_name='defaultroutergroupmodel', name='permissions', field=models.ManyToManyField(to='functional.DefaultRouterPermissionModel'), ), ] drf-extensions-0.6.0/tests_app/tests/functional/migrations/__init__.py000066400000000000000000000000001361353156700262600ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/mixins/000077500000000000000000000000001361353156700233145ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/mixins/__init__.py000066400000000000000000000000001361353156700254130ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/mixins/detail_serializer_mixin/000077500000000000000000000000001361353156700302135ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/mixins/detail_serializer_mixin/__init__.py000066400000000000000000000000011361353156700323130ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/functional/mixins/detail_serializer_mixin/models.py000066400000000000000000000003011361353156700320420ustar00rootroot00000000000000from django.db import models class Comment(models.Model): email = models.EmailField() content = models.CharField(max_length=200) created = models.DateTimeField(auto_now_add=True) drf-extensions-0.6.0/tests_app/tests/functional/mixins/detail_serializer_mixin/serializers.py000066400000000000000000000006501361353156700331220ustar00rootroot00000000000000from rest_framework import serializers from .models import Comment class CommentSerializer(serializers.ModelSerializer): class Meta: model = Comment fields = ( 'id', 'email', ) class CommentDetailSerializer(serializers.ModelSerializer): class Meta: model = Comment fields = ( 'id', 'email', 'content', )drf-extensions-0.6.0/tests_app/tests/functional/mixins/detail_serializer_mixin/tests.py000066400000000000000000000074611361353156700317370ustar00rootroot00000000000000import datetime from django.test import TestCase, override_settings # todo: use from rest_framework when released from rest_framework_extensions.test import APIRequestFactory from .models import Comment factory = APIRequestFactory() @override_settings(ROOT_URLCONF='tests_app.tests.functional.mixins.detail_serializer_mixin.urls') class DetailSerializerMixinTest_serializer_detail_class(TestCase): def setUp(self): self.comment = Comment.objects.create( id=1, email='example@ya.ru', content='Hello world', created=datetime.datetime.now() ) def test_serializer_class_response(self): resp = self.client.get('/comments/') expected = [{ 'id': 1, 'email': 'example@ya.ru' }] self.assertEqual(resp.data, expected) def test_serializer_detail_class_response(self): resp = self.client.get('/comments/1/') expected = { 'id': 1, 'email': 'example@ya.ru', 'content': 'Hello world', } self.assertEqual(resp.data, expected, 'should use detail serializer for detail endpoint') def test_view_with_mixin_and_without__serializer_detail_class__should_raise_exception(self): msg = "'CommentWithoutDetailSerializerClassViewSet' should include a 'serializer_detail_class' attribute" self.assertRaisesMessage(AssertionError, msg, self.client.get, '/comments-2/') @override_settings(ROOT_URLCONF='tests_app.tests.functional.mixins.detail_serializer_mixin.urls') class DetailSerializerMixin_queryset_detail(TestCase): def setUp(self): self.comments = [ Comment.objects.create( id=1, email='example@ya.ru', content='Hello world', created=datetime.datetime.now() ), Comment.objects.create( id=2, email='example2@ya.ru', content='Hello world 2', created=datetime.datetime.now() ), ] def test_list_should_use_default_queryset_method(self): resp = self.client.get('/comments-3/') expected = [{ 'id': 2, 'email': 'example2@ya.ru' }] self.assertEqual(resp.data, expected) def test_detail_view_should_use_default_queryset_if_queryset_detail_not_specified(self): resp = self.client.get('/comments-3/1/') self.assertEqual(resp.status_code, 404) resp = self.client.get('/comments-3/2/') expected = { 'id': 2, 'email': 'example2@ya.ru', 'content': 'Hello world 2', } self.assertEqual(resp.data, expected) def test_list_should_use_default_queryset_method_if_queryset_detail_specified(self): resp = self.client.get('/comments-4/') expected = [{ 'id': 2, 'email': 'example2@ya.ru' }] self.assertEqual(resp.data, expected) def test_detail_view_should_use_custom_queryset_if_queryset_detail_specified(self): resp = self.client.get('/comments-4/2/') self.assertEqual(resp.status_code, 404) resp = self.client.get('/comments-4/1/') expected = { 'id': 1, 'email': 'example@ya.ru', 'content': 'Hello world', } self.assertEqual(resp.data, expected) def test_nested_model_view_with_mixin_should_use_get_detail_queryset(self): """ Regression tests for https://github.com/chibisov/drf-extensions/pull/24 """ resp = self.client.get('/comments-5/1/') expected = { 'id': 1, 'email': 'example@ya.ru', 'content': 'Hello world', } self.assertEqual(resp.status_code, 200) self.assertEqual(resp.data, expected) drf-extensions-0.6.0/tests_app/tests/functional/mixins/detail_serializer_mixin/urls.py000066400000000000000000000013131361353156700315500ustar00rootroot00000000000000from rest_framework import routers from .views import ( CommentViewSet, CommentWithoutDetailSerializerClassViewSet, CommentWithIdTwoViewSet, CommentWithIdTwoAndIdOneForDetailViewSet, CommentWithDetailSerializerAndNoArgsForGetQuerySetViewSet ) viewset_router = routers.DefaultRouter() viewset_router.register('comments', CommentViewSet) viewset_router.register('comments-2', CommentWithoutDetailSerializerClassViewSet) viewset_router.register('comments-3', CommentWithIdTwoViewSet) viewset_router.register('comments-4', CommentWithIdTwoAndIdOneForDetailViewSet) viewset_router.register('comments-5', CommentWithDetailSerializerAndNoArgsForGetQuerySetViewSet) urlpatterns = viewset_router.urls drf-extensions-0.6.0/tests_app/tests/functional/mixins/detail_serializer_mixin/views.py000066400000000000000000000030411361353156700317200ustar00rootroot00000000000000from rest_framework import viewsets from rest_framework_extensions.mixins import DetailSerializerMixin from .models import Comment from .serializers import CommentSerializer, CommentDetailSerializer class CommentViewSet(DetailSerializerMixin, viewsets.ReadOnlyModelViewSet): serializer_class = CommentSerializer serializer_detail_class = CommentDetailSerializer queryset = Comment.objects.all() class CommentWithoutDetailSerializerClassViewSet(DetailSerializerMixin, viewsets.ReadOnlyModelViewSet): serializer_class = CommentSerializer queryset = Comment.objects.all() class CommentWithIdTwoViewSet(DetailSerializerMixin, viewsets.ReadOnlyModelViewSet): serializer_class = CommentSerializer serializer_detail_class = CommentDetailSerializer queryset = Comment.objects.filter(id=2) class CommentWithIdTwoAndIdOneForDetailViewSet(DetailSerializerMixin, viewsets.ReadOnlyModelViewSet): serializer_class = CommentSerializer serializer_detail_class = CommentDetailSerializer queryset = Comment.objects.filter(id=2) queryset_detail = Comment.objects.filter(id=1) class CommentWithDetailSerializerAndNoArgsForGetQuerySetViewSet(DetailSerializerMixin, viewsets.ModelViewSet): """ For regression tests https://github.com/chibisov/drf-extensions/pull/24 """ serializer_class = CommentSerializer serializer_detail_class = CommentDetailSerializer queryset = Comment.objects.all() queryset_detail = Comment.objects.filter(id=1) def get_queryset(self): return super().get_queryset() drf-extensions-0.6.0/tests_app/tests/functional/mixins/list_destroy_model_mixin/000077500000000000000000000000001361353156700304245ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/mixins/list_destroy_model_mixin/__init__.py000066400000000000000000000000011361353156700325240ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/functional/mixins/list_destroy_model_mixin/models.py000066400000000000000000000001641361353156700322620ustar00rootroot00000000000000from django.db import models class CommentForListDestroyModelMixin(models.Model): email = models.EmailField() drf-extensions-0.6.0/tests_app/tests/functional/mixins/list_destroy_model_mixin/tests.py000066400000000000000000000063731361353156700321510ustar00rootroot00000000000000from django.test import override_settings from rest_framework_extensions.test import APITestCase from rest_framework_extensions.settings import extensions_api_settings from rest_framework_extensions import utils from .models import CommentForListDestroyModelMixin as Comment from tests_app.testutils import override_extensions_api_settings @override_settings(ROOT_URLCONF='tests_app.tests.functional.mixins.list_destroy_model_mixin.urls') class ListDestroyModelMixinTest(APITestCase): def setUp(self): self.comments = [ Comment.objects.create( id=1, email='example@ya.ru' ), Comment.objects.create( id=2, email='example@gmail.com' ) ] self.protection_headers = { utils.prepare_header_name(extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME): 'true' } def test_simple_response(self): resp = self.client.get('/comments/') expected = [ { 'id': 1, 'email': 'example@ya.ru' }, { 'id': 2, 'email': 'example@gmail.com' } ] self.assertEqual(resp.data, expected) def test_filter_works(self): resp = self.client.get('/comments/?id=1') expected = [ { 'id': 1, 'email': 'example@ya.ru' } ] self.assertEqual(resp.data, expected) def test_destroy_instance(self): resp = self.client.delete('/comments/1/') self.assertEqual(resp.status_code, 204) self.assertFalse(1 in Comment.objects.values_list('pk', flat=True)) def test_bulk_destroy__without_protection_header(self): resp = self.client.delete('/comments/') self.assertEqual(resp.status_code, 400) expected_message = { 'detail': 'Header \'{0}\' should be provided for bulk operation.'.format( extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME ) } self.assertEqual(resp.data, expected_message) def test_bulk_destroy__with_protection_header(self): resp = self.client.delete('/comments/', **self.protection_headers) self.assertEqual(resp.status_code, 204) self.assertEqual(Comment.objects.count(), 0) @override_extensions_api_settings(DEFAULT_BULK_OPERATION_HEADER_NAME=None) def test_bulk_destroy__without_protection_header__and_with_turned_off_protection_header(self): resp = self.client.delete('/comments/') self.assertEqual(resp.status_code, 204) self.assertEqual(Comment.objects.count(), 0) def test_bulk_destroy__should_destroy_filtered_queryset(self): resp = self.client.delete('/comments/?id=1', **self.protection_headers) self.assertEqual(resp.status_code, 204) self.assertEqual(Comment.objects.count(), 1) self.assertEqual(Comment.objects.all()[0], self.comments[1]) def test_bulk_destroy__should_not_destroy_if_client_has_no_permissions(self): resp = self.client.delete('/comments-with-permission/', **self.protection_headers) self.assertEqual(resp.status_code, 404) self.assertEqual(Comment.objects.count(), 2) drf-extensions-0.6.0/tests_app/tests/functional/mixins/list_destroy_model_mixin/urls.py000066400000000000000000000004721361353156700317660ustar00rootroot00000000000000from rest_framework import routers from .views import CommentViewSet, CommentViewSetWithPermissions viewset_router = routers.DefaultRouter() viewset_router.register('comments', CommentViewSet) viewset_router.register('comments-with-permissions', CommentViewSetWithPermissions) urlpatterns = viewset_router.urls drf-extensions-0.6.0/tests_app/tests/functional/mixins/list_destroy_model_mixin/views.py000066400000000000000000000016521361353156700321370ustar00rootroot00000000000000import django_filters from rest_framework import viewsets, serializers from rest_framework import filters from rest_framework.permissions import DjangoModelPermissions from rest_framework_extensions.bulk_operations.mixins import ListDestroyModelMixin from .models import CommentForListDestroyModelMixin as Comment class CommentFilter(django_filters.FilterSet): class Meta: model = Comment fields = [ 'id' ] class CommentSerializer(serializers.ModelSerializer): class Meta: model = Comment fields = '__all__' class CommentViewSet(ListDestroyModelMixin, viewsets.ModelViewSet): queryset = Comment.objects.all() serializer_class = CommentSerializer filter_backends = (django_filters.rest_framework.DjangoFilterBackend,) filterset_class = CommentFilter class CommentViewSetWithPermissions(CommentViewSet): permission_classes = (DjangoModelPermissions,) drf-extensions-0.6.0/tests_app/tests/functional/mixins/list_update_model_mixin/000077500000000000000000000000001361353156700302155ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/mixins/list_update_model_mixin/__init__.py000066400000000000000000000000011361353156700323150ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/functional/mixins/list_update_model_mixin/models.py000066400000000000000000000005611361353156700320540ustar00rootroot00000000000000from django.db import models class CommentForListUpdateModelMixin(models.Model): email = models.EmailField() class UserForListUpdateModelMixin(models.Model): email = models.EmailField() name = models.CharField(max_length=10) age = models.IntegerField() last_name = models.CharField(max_length=10) password = models.CharField(max_length=100) drf-extensions-0.6.0/tests_app/tests/functional/mixins/list_update_model_mixin/serializers.py000066400000000000000000000012101361353156700331150ustar00rootroot00000000000000from rest_framework import serializers from .models import ( UserForListUpdateModelMixin as User, CommentForListUpdateModelMixin as Comment, ) class UserSerializer(serializers.ModelSerializer): surname = serializers.CharField(source='last_name') class Meta: model = User extra_kwargs = {'password': {'write_only': True}} read_only_fields = ('name',) fields = [ 'id', 'age', 'name', 'surname', 'password' ] class CommentSerializer(serializers.ModelSerializer): class Meta: model = Comment fields = '__all__' drf-extensions-0.6.0/tests_app/tests/functional/mixins/list_update_model_mixin/tests.py000066400000000000000000000172041361353156700317350ustar00rootroot00000000000000import json import unittest import django from django.test import override_settings from rest_framework_extensions.test import APITestCase from rest_framework_extensions.settings import extensions_api_settings from rest_framework_extensions import utils from .models import ( CommentForListUpdateModelMixin as Comment, UserForListUpdateModelMixin as User ) from tests_app.testutils import override_extensions_api_settings @override_settings(ROOT_URLCONF='tests_app.tests.functional.mixins.list_update_model_mixin.urls') class ListUpdateModelMixinTest(APITestCase): def setUp(self): self.comments = [ Comment.objects.create( id=1, email='example@ya.ru' ), Comment.objects.create( id=2, email='example@gmail.com' ) ] self.protection_headers = { utils.prepare_header_name(extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME): 'true' } self.patch_data = { 'email': 'example@yandex.ru' } def test_simple_response(self): resp = self.client.get('/comments/') expected = [ { 'id': 1, 'email': 'example@ya.ru' }, { 'id': 2, 'email': 'example@gmail.com' } ] self.assertEqual(resp.data, expected) def test_filter_works(self): resp = self.client.get('/comments/?id=1') expected = [ { 'id': 1, 'email': 'example@ya.ru' } ] self.assertEqual(resp.data, expected) def test_update_instance(self): data = { 'id': 1, 'email': 'example@yandex.ru' } resp = self.client.put('/comments/1/', data=json.dumps(data), content_type='application/json') self.assertEqual(resp.status_code, 200) self.assertEqual(Comment.objects.get(pk=1).email, 'example@yandex.ru') def test_partial_update_instance(self): data = { 'id': 1, 'email': 'example@yandex.ru' } resp = self.client.patch('/comments/1/', data=json.dumps(data), content_type='application/json') self.assertEqual(resp.status_code, 200) self.assertEqual(Comment.objects.get(pk=1).email, 'example@yandex.ru') def test_bulk_partial_update__without_protection_header(self): resp = self.client.patch('/comments/', data=json.dumps(self.patch_data), content_type='application/json') self.assertEqual(resp.status_code, 400) expected_message = { 'detail': 'Header \'{0}\' should be provided for bulk operation.'.format( extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME ) } self.assertEqual(resp.data, expected_message) def test_bulk_partial_update__with_protection_header(self): resp = self.client.patch('/comments/', data=json.dumps(self.patch_data), content_type='application/json', **self.protection_headers) self.assertEqual(resp.status_code, 204) for comment in Comment.objects.all(): self.assertEqual(comment.email, self.patch_data['email']) @override_extensions_api_settings(DEFAULT_BULK_OPERATION_HEADER_NAME=None) def test_bulk_partial_update__without_protection_header__and_with_turned_off_protection_header(self): resp = self.client.patch('/comments/', data=json.dumps(self.patch_data), content_type='application/json', **self.protection_headers) self.assertEqual(resp.status_code, 204) for comment in Comment.objects.all(): self.assertEqual(comment.email, self.patch_data['email']) def test_bulk_partial_update__should_update_filtered_queryset(self): resp = self.client.patch('/comments/?id=1', data=json.dumps(self.patch_data), content_type='application/json', **self.protection_headers) self.assertEqual(resp.status_code, 204) self.assertEqual(Comment.objects.get(pk=1).email, self.patch_data['email']) self.assertEqual(Comment.objects.get(pk=2).email, self.comments[1].email) def test_bulk_partial_update__should_not_update_if_client_has_no_permissions(self): resp = self.client.patch('/comments-with-permission/', data=json.dumps(self.patch_data), content_type='application/json', **self.protection_headers) self.assertEqual(resp.status_code, 404) for i, comment in enumerate(Comment.objects.all()): self.assertEqual(comment.email, self.comments[i].email) @override_settings(ROOT_URLCONF='tests_app.tests.functional.mixins.list_update_model_mixin.urls') class ListUpdateModelMixinTestBehaviour__serializer_fields(APITestCase): def setUp(self): self.user = User.objects.create( id=1, name='Gennady', age=24, last_name='Chibisov', email='example@ya.ru', password='somepassword' ) self.headers = { utils.prepare_header_name(extensions_api_settings.DEFAULT_BULK_OPERATION_HEADER_NAME): 'true' } def get_fresh_user(self): return User.objects.get(pk=self.user.pk) def test_simple_response(self): resp = self.client.get('/users/') expected = [ { 'id': 1, 'age': 24, 'name': 'Gennady', 'surname': 'Chibisov' } ] self.assertEqual(resp.data, expected) def test_invalid_for_db_data(self): data = { 'age': 'Not integer value' } try: resp = self.client.patch('/users/', data=json.dumps(data), content_type='application/json', **self.headers) except ValueError: self.fail('Errors with invalid for DB data should be caught') else: self.assertEqual(resp.status_code, 400) if django.VERSION < (3, 0, 0): expected_message = { 'detail': "invalid literal for int() with base 10: 'Not integer value'" } else: expected_message = { 'detail': "Field 'age' expected a number but got 'Not integer value'." } self.assertEqual(resp.data, expected_message) def test_should_use_source_if_it_set_in_serializer(self): data = { 'surname': 'Ivanov' } resp = self.client.patch('/users/', data=json.dumps(data), content_type='application/json', **self.headers) self.assertEqual(resp.status_code, 204) self.assertEqual(self.get_fresh_user().last_name, data['surname']) def test_should_update_write_only_fields(self): data = { 'password': '123' } resp = self.client.patch('/users/', data=json.dumps(data), content_type='application/json', **self.headers) self.assertEqual(resp.status_code, 204) self.assertEqual(self.get_fresh_user().password, data['password']) def test_should_not_update_read_only_fields(self): data = { 'name': 'Ivan' } resp = self.client.patch('/users/', data=json.dumps(data), content_type='application/json', **self.headers) self.assertEqual(resp.status_code, 204) self.assertEqual(self.get_fresh_user().name, self.user.name) def test_should_not_update_hidden_fields(self): data = { 'email': 'example@gmail.com' } resp = self.client.patch('/users/', data=json.dumps(data), content_type='application/json', **self.headers) self.assertEqual(resp.status_code, 204) self.assertEqual(self.get_fresh_user().email, self.user.email) drf-extensions-0.6.0/tests_app/tests/functional/mixins/list_update_model_mixin/urls.py000066400000000000000000000005651361353156700315620ustar00rootroot00000000000000from rest_framework import routers from .views import CommentViewSet, CommentViewSetWithPermissions, UserViewSet viewset_router = routers.DefaultRouter() viewset_router.register('comments', CommentViewSet) viewset_router.register('comments-with-permissions', CommentViewSetWithPermissions) viewset_router.register('users', UserViewSet) urlpatterns = viewset_router.urls drf-extensions-0.6.0/tests_app/tests/functional/mixins/list_update_model_mixin/views.py000066400000000000000000000020041361353156700317200ustar00rootroot00000000000000import django_filters from rest_framework import viewsets from rest_framework import filters from rest_framework.permissions import DjangoModelPermissions from rest_framework_extensions.mixins import ListUpdateModelMixin from .models import ( CommentForListUpdateModelMixin as Comment, UserForListUpdateModelMixin as User ) from .serializers import UserSerializer, CommentSerializer class CommentFilter(django_filters.FilterSet): class Meta: model = Comment fields = [ 'id' ] class CommentViewSet(ListUpdateModelMixin, viewsets.ModelViewSet): queryset = Comment.objects.all() serializer_class = CommentSerializer filter_backends = (django_filters.rest_framework.DjangoFilterBackend,) filterset_class = CommentFilter class CommentViewSetWithPermissions(CommentViewSet): permission_classes = (DjangoModelPermissions,) class UserViewSet(ListUpdateModelMixin, viewsets.ModelViewSet): queryset = User.objects.all() serializer_class = UserSerializerdrf-extensions-0.6.0/tests_app/tests/functional/mixins/paginate_by_max_mixin/000077500000000000000000000000001361353156700276475ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/mixins/paginate_by_max_mixin/__init__.py000066400000000000000000000000011361353156700317470ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/functional/mixins/paginate_by_max_mixin/models.py000066400000000000000000000004011361353156700314770ustar00rootroot00000000000000from django.db import models class CommentForPaginateByMaxMixin(models.Model): email = models.EmailField() content = models.CharField(max_length=200) created = models.DateTimeField(auto_now_add=True) class Meta: ordering = ['id'] drf-extensions-0.6.0/tests_app/tests/functional/mixins/paginate_by_max_mixin/pagination.py000066400000000000000000000006751361353156700323620ustar00rootroot00000000000000from rest_framework_extensions.mixins import PaginateByMaxMixin from rest_framework.pagination import PageNumberPagination class WithMaxPagination(PaginateByMaxMixin, PageNumberPagination): page_size = 10 page_size_query_param = 'limit' max_page_size = 20 class FlexiblePagination(PageNumberPagination): page_size = 10 page_size_query_param = 'page_size' class FixedPagination(PageNumberPagination): page_size = 10 drf-extensions-0.6.0/tests_app/tests/functional/mixins/paginate_by_max_mixin/serializers.py000066400000000000000000000004221361353156700325530ustar00rootroot00000000000000from rest_framework import serializers from .models import CommentForPaginateByMaxMixin class CommentSerializer(serializers.ModelSerializer): class Meta: model = CommentForPaginateByMaxMixin fields = ( 'id', 'email', ) drf-extensions-0.6.0/tests_app/tests/functional/mixins/paginate_by_max_mixin/tests.py000066400000000000000000000053151361353156700313670ustar00rootroot00000000000000import datetime from django.test import TestCase, override_settings from .models import CommentForPaginateByMaxMixin @override_settings(ROOT_URLCONF='tests_app.tests.functional.mixins.paginate_by_max_mixin.urls') class PaginateByMaxMixinTest(TestCase): def setUp(self): for i in range(30): CommentForPaginateByMaxMixin.objects.create( email='example@ya.ru', content='Hello world', created=datetime.datetime.now() ) def test_default_page_size(self): resp = self.client.get('/comments/') self.assertEqual(len(resp.data['results']), 10) def test_custom_page_size__less_then_maximum(self): resp = self.client.get('/comments/?limit=15') self.assertEqual(len(resp.data['results']), 15) def test_custom_page_size__more_then_maximum(self): resp = self.client.get('/comments/?limit=25') self.assertEqual(len(resp.data['results']), 20) def test_custom_page_size_with_max_value(self): resp = self.client.get('/comments/?limit=max') self.assertEqual(len(resp.data['results']), 20) def test_custom_page_size_with_max_value__for_view_without__paginate_by_param__attribute(self): resp = self.client.get( '/comments-without-paginate-by-param-attribute/?page_size=max') self.assertEqual(len(resp.data['results']), 10) def test_custom_page_size_with_max_value__for_view_without__max_paginate_by__attribute(self): resp = self.client.get( '/comments-without-max-paginate-by-attribute/?page_size=max') self.assertEqual(len(resp.data['results']), 10) @override_settings(ROOT_URLCONF='tests_app.tests.functional.mixins.paginate_by_max_mixin.urls') class PaginateByMaxMixinTestBehavior__should_not_affect_view_if_DRF_does_not_supports__max_paginate_by(TestCase): def setUp(self): for i in range(30): CommentForPaginateByMaxMixin.objects.create( email='example@ya.ru', content='Hello world', created=datetime.datetime.now() ) def test_default_page_size(self): resp = self.client.get('/comments/') self.assertEqual(len(resp.data['results']), 10) def test_custom_page_size__less_then_maximum(self): resp = self.client.get('/comments/?limit=15') self.assertEqual(len(resp.data['results']), 15) def test_custom_page_size__more_then_maximum(self): resp = self.client.get('/comments/?limit=25') self.assertEqual(len(resp.data['results']), 20) def test_custom_page_size_with_max_value(self): resp = self.client.get('/comments/?limit=max') self.assertEqual(len(resp.data['results']), 20) drf-extensions-0.6.0/tests_app/tests/functional/mixins/paginate_by_max_mixin/urls.py000066400000000000000000000010141361353156700312020ustar00rootroot00000000000000from rest_framework import routers from .views import ( CommentViewSet, CommentWithoutPaginateByParamViewSet, CommentWithoutMaxPaginateByAttributeViewSet, ) viewset_router = routers.DefaultRouter() viewset_router.register('comments', CommentViewSet) viewset_router.register('comments-without-paginate-by-param-attribute', CommentWithoutPaginateByParamViewSet) viewset_router.register('comments-without-max-paginate-by-attribute', CommentWithoutMaxPaginateByAttributeViewSet) urlpatterns = viewset_router.urls drf-extensions-0.6.0/tests_app/tests/functional/mixins/paginate_by_max_mixin/views.py000066400000000000000000000015321361353156700313570ustar00rootroot00000000000000from rest_framework import viewsets from .pagination import WithMaxPagination, FixedPagination, FlexiblePagination from .models import CommentForPaginateByMaxMixin from .serializers import CommentSerializer class CommentViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = CommentSerializer pagination_class = WithMaxPagination queryset = CommentForPaginateByMaxMixin.objects.all().order_by('id') class CommentWithoutPaginateByParamViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = CommentSerializer pagination_class = FixedPagination queryset = CommentForPaginateByMaxMixin.objects.all() class CommentWithoutMaxPaginateByAttributeViewSet(viewsets.ReadOnlyModelViewSet): pagination_class = FlexiblePagination serializer_class = CommentSerializer queryset = CommentForPaginateByMaxMixin.objects.all() drf-extensions-0.6.0/tests_app/tests/functional/models.py000066400000000000000000000010011361353156700236320ustar00rootroot00000000000000# from .concurrency.conditional_request.models import * from .key_constructor.bits.models import * from .mixins.detail_serializer_mixin.models import * from .mixins.list_destroy_model_mixin.models import * from .mixins.list_update_model_mixin.models import * from .mixins.paginate_by_max_mixin.models import * from .permissions.extended_django_object_permissions.models import * from .routers.models import * from .routers.extended_default_router.models import * from .routers.nested_router_mixin.models import * drf-extensions-0.6.0/tests_app/tests/functional/permissions/000077500000000000000000000000001361353156700243605ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/permissions/__init__.py000066400000000000000000000000011361353156700264600ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/functional/permissions/extended_django_object_permissions/000077500000000000000000000000001361353156700334635ustar00rootroot00000000000000__init__.py000066400000000000000000000000011361353156700355040ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/permissions/extended_django_object_permissions models.py000066400000000000000000000005321361353156700352410ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/permissions/extended_django_object_permissionsimport django from django.db import models class PermissionsComment(models.Model): text = models.CharField(max_length=100) class Meta: if django.VERSION < (2, 1): permissions = ( ('view_permissionscomment', 'Can view comment'), # add, change, delete built in to django ) tests.py000066400000000000000000000224101361353156700351170ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/permissions/extended_django_object_permissionsimport json from django.contrib.auth.models import User, Group, Permission from django.contrib.contenttypes.models import ContentType from django.test import override_settings from rest_framework import status from rest_framework_extensions.test import APITestCase from tests_app.testutils import basic_auth_header from .models import PermissionsComment class ExtendedDjangoObjectPermissionTestMixin: def setUp(self): from guardian.shortcuts import assign_perm # create users create = User.objects.create_user users = { 'fullaccess': create('fullaccess', 'fullaccess@example.com', 'password'), 'readonly': create('readonly', 'readonly@example.com', 'password'), 'writeonly': create('writeonly', 'writeonly@example.com', 'password'), 'deleteonly': create('deleteonly', 'deleteonly@example.com', 'password'), } # create custom permission Permission.objects.get_or_create( codename='view_permissionscomment', content_type=ContentType.objects.get_for_model(PermissionsComment), defaults={'name': 'Can view comment'}, ) # give everyone model level permissions, as we are not testing those everyone = Group.objects.create(name='everyone') model_name = PermissionsComment._meta.model_name app_label = PermissionsComment._meta.app_label f = '{0}_{1}'.format perms = { 'view': f('view', model_name), 'change': f('change', model_name), 'delete': f('delete', model_name) } for perm in perms.values(): perm = '{0}.{1}'.format(app_label, perm) assign_perm(perm, everyone) everyone.user_set.add(*users.values()) # appropriate object level permissions readers = Group.objects.create(name='readers') writers = Group.objects.create(name='writers') deleters = Group.objects.create(name='deleters') model = PermissionsComment.objects.create(text='foo', id=1) assign_perm(perms['view'], readers, model) assign_perm(perms['change'], writers, model) assign_perm(perms['delete'], deleters, model) readers.user_set.add(users['fullaccess'], users['readonly']) writers.user_set.add(users['fullaccess'], users['writeonly']) deleters.user_set.add(users['fullaccess'], users['deleteonly']) self.credentials = {} for user in users.values(): self.credentials[user.username] = basic_auth_header(user.username, 'password') @override_settings(ROOT_URLCONF='tests_app.tests.functional.permissions.extended_django_object_permissions.urls') class ExtendedDjangoObjectPermissionsTest_should_inherit_standard(ExtendedDjangoObjectPermissionTestMixin, APITestCase): # Delete def test_can_delete_permissions(self): response = self.client.delete( '/comments/1/', **{'HTTP_AUTHORIZATION': self.credentials['deleteonly']}) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) def test_cannot_delete_permissions(self): response = self.client.delete( '/comments/1/', **{'HTTP_AUTHORIZATION': self.credentials['readonly']}) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # Update def test_can_update_permissions(self): response = self.client.patch( '/comments/1/', content_type='application/json', data=json.dumps({'text': 'foobar'}), **{ 'HTTP_AUTHORIZATION': self.credentials['writeonly'] } ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data.get('text'), 'foobar') def test_cannot_update_permissions(self): response = self.client.patch( '/comments/1/', content_type='application/json', data=json.dumps({'text': 'foobar'}), **{ 'HTTP_AUTHORIZATION': self.credentials['deleteonly'] } ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) def test_cannot_update_permissions_non_existing(self): response = self.client.patch( '/comments/999/', content_type='application/json', data=json.dumps({'text': 'foobar'}), **{ 'HTTP_AUTHORIZATION': self.credentials['deleteonly'] } ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) # Read def test_can_read_permissions(self): response = self.client.get( '/comments/1/', **{'HTTP_AUTHORIZATION': self.credentials['readonly']}) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_cannot_read_permissions(self): response = self.client.get( '/comments/1/', **{'HTTP_AUTHORIZATION': self.credentials['writeonly']}) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) # Read list def test_can_read_list_permissions(self): response = self.client.get( '/comments-permission-filter-backend/', **{'HTTP_AUTHORIZATION': self.credentials['readonly']} ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data[0].get('id'), 1) def test_cannot_read_list_permissions(self): response = self.client.get( '/comments-permission-filter-backend/', **{'HTTP_AUTHORIZATION': self.credentials['writeonly']} ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertListEqual(response.data, []) @override_settings(ROOT_URLCONF='tests_app.tests.functional.permissions.extended_django_object_permissions.urls') class ExtendedDjangoObjectPermissionsTest_without_hiding_forbidden_objects(ExtendedDjangoObjectPermissionTestMixin, APITestCase): # Delete def test_can_delete_permissions(self): response = self.client.delete( '/comments-without-hiding-forbidden-objects/1/', **{'HTTP_AUTHORIZATION': self.credentials['deleteonly']} ) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) def test_cannot_delete_permissions(self): response = self.client.delete( '/comments-without-hiding-forbidden-objects/1/', **{'HTTP_AUTHORIZATION': self.credentials['readonly']} ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # Update def test_can_update_permissions(self): response = self.client.patch( '/comments-without-hiding-forbidden-objects/1/', content_type='application/json', data=json.dumps({'text': 'foobar'}), **{ 'HTTP_AUTHORIZATION': self.credentials['writeonly'] } ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data.get('text'), 'foobar') def test_cannot_update_permissions(self): response = self.client.patch( '/comments-without-hiding-forbidden-objects/1/', content_type='application/json', data=json.dumps({'text': 'foobar'}), **{ 'HTTP_AUTHORIZATION': self.credentials['deleteonly'] } ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_cannot_update_permissions_non_existing(self): response = self.client.patch( '/comments-without-hiding-forbidden-objects/999/', content_type='application/json', data=json.dumps({'text': 'foobar'}), **{ 'HTTP_AUTHORIZATION': self.credentials['deleteonly'] } ) self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) # Read def test_can_read_permissions(self): response = self.client.get( '/comments-without-hiding-forbidden-objects/1/', **{'HTTP_AUTHORIZATION': self.credentials['readonly']} ) self.assertEqual(response.status_code, status.HTTP_200_OK) def test_cannot_read_permissions(self): response = self.client.get( '/comments-without-hiding-forbidden-objects/1/', **{'HTTP_AUTHORIZATION': self.credentials['writeonly']} ) self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) # Read list def test_can_read_list_permissions(self): response = self.client.get( '/comments-without-hiding-forbidden-objects-permission-filter-backend/', **{'HTTP_AUTHORIZATION': self.credentials['readonly']} ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data[0].get('id'), 1) def test_cannot_read_list_permissions(self): response = self.client.get( '/comments-without-hiding-forbidden-objects-permission-filter-backend/', **{'HTTP_AUTHORIZATION': self.credentials['writeonly']} ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertListEqual(response.data, []) urls.py000066400000000000000000000013671361353156700347520ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/permissions/extended_django_object_permissionsfrom rest_framework import routers from .views import ( CommentViewSet, CommentViewSetPermissionFilterBackend, CommentViewSetWithoutHidingForbiddenObjects, CommentViewSetWithoutHidingForbiddenObjectsPermissionFilterBackend ) viewset_router = routers.DefaultRouter() viewset_router.register('comments', CommentViewSet) viewset_router.register('comments-permission-filter-backend', CommentViewSetPermissionFilterBackend) viewset_router.register('comments-without-hiding-forbidden-objects', CommentViewSetWithoutHidingForbiddenObjects) viewset_router.register( 'comments-without-hiding-forbidden-objects-permission-filter-backend', CommentViewSetWithoutHidingForbiddenObjectsPermissionFilterBackend ) urlpatterns = viewset_router.urls views.py000066400000000000000000000036721361353156700351230ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/permissions/extended_django_object_permissionsfrom rest_framework import viewsets, serializers from rest_framework import authentication try: # djangorestframework >= 3.9 from rest_framework_guardian.filters import DjangoObjectPermissionsFilter except ImportError: from rest_framework.filters import DjangoObjectPermissionsFilter try: from rest_framework_extensions.permissions import ExtendedDjangoObjectPermissions except ImportError: class ExtendedDjangoObjectPermissions: pass from .models import PermissionsComment class CommentObjectPermissions(ExtendedDjangoObjectPermissions): perms_map = { 'GET': ['%(app_label)s.view_%(model_name)s'], 'OPTIONS': ['%(app_label)s.view_%(model_name)s'], 'HEAD': ['%(app_label)s.view_%(model_name)s'], 'POST': ['%(app_label)s.add_%(model_name)s'], 'PUT': ['%(app_label)s.change_%(model_name)s'], 'PATCH': ['%(app_label)s.change_%(model_name)s'], 'DELETE': ['%(app_label)s.delete_%(model_name)s'], } class PermissionsCommentSerializer(serializers.ModelSerializer): class Meta: model = PermissionsComment fields = '__all__' class CommentObjectPermissionsWithoutHidingForbiddenObjects(CommentObjectPermissions): hide_forbidden_for_read_objects = False class CommentViewSet(viewsets.ModelViewSet): queryset = PermissionsComment.objects.all() serializer_class = PermissionsCommentSerializer authentication_classes = [authentication.BasicAuthentication] permission_classes = (CommentObjectPermissions,) class CommentViewSetPermissionFilterBackend(CommentViewSet): filter_backends = (DjangoObjectPermissionsFilter,) class CommentViewSetWithoutHidingForbiddenObjects(CommentViewSet): permission_classes = (CommentObjectPermissionsWithoutHidingForbiddenObjects,) class CommentViewSetWithoutHidingForbiddenObjectsPermissionFilterBackend(CommentViewSetWithoutHidingForbiddenObjects): filter_backends = (DjangoObjectPermissionsFilter,)drf-extensions-0.6.0/tests_app/tests/functional/routers/000077500000000000000000000000001361353156700235105ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/routers/__init__.py000066400000000000000000000000011361353156700256100ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/functional/routers/extended_default_router/000077500000000000000000000000001361353156700304145ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/routers/extended_default_router/__init__.py000066400000000000000000000000011361353156700325140ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/functional/routers/extended_default_router/models.py000066400000000000000000000007231361353156700322530ustar00rootroot00000000000000from django.db import models class DefaultRouterUserModel(models.Model): name = models.CharField(max_length=10) groups = models.ManyToManyField('DefaultRouterGroupModel', related_name='user_groups') class DefaultRouterGroupModel(models.Model): name = models.CharField(max_length=10) permissions = models.ManyToManyField('DefaultRouterPermissionModel') class DefaultRouterPermissionModel(models.Model): name = models.CharField(max_length=10) drf-extensions-0.6.0/tests_app/tests/functional/routers/extended_default_router/tests.py000066400000000000000000000017511361353156700321340ustar00rootroot00000000000000from django.test import override_settings from django.urls import NoReverseMatch from rest_framework_extensions.test import APITestCase @override_settings(ROOT_URLCONF='tests_app.tests.functional.routers.extended_default_router.urls') class ExtendedDefaultRouterTestBehaviour(APITestCase): def test_index_page(self): try: response = self.client.get('/') except NoReverseMatch: issue = 'https://github.com/chibisov/drf-extensions/issues/14' self.fail('DefaultRouter tries to reverse nested routes and breaks with error. NoReverseMatch should be ' 'handled for nested routes. They must be excluded from index page. ' + issue) self.assertEqual(response.status_code, 200) expected = { 'users': 'http://testserver/users/', 'groups': 'http://testserver/groups/', 'permissions': 'http://testserver/permissions/', } self.assertEqual(response.data, expected) drf-extensions-0.6.0/tests_app/tests/functional/routers/extended_default_router/urls.py000066400000000000000000000011731361353156700317550ustar00rootroot00000000000000from rest_framework_extensions.routers import ExtendedDefaultRouter from .views import ( UserViewSet, GroupViewSet, PermissionViewSet, ) router = ExtendedDefaultRouter() # nested routes ( router.register(r'users', UserViewSet) .register(r'groups', GroupViewSet, 'users-group', parents_query_lookups=['user_groups']) .register(r'permissions', PermissionViewSet, 'users-groups-permission', parents_query_lookups=['group__user', 'group']) ) # simple routes router.register(r'groups', GroupViewSet, 'group') router.register(r'permissions', PermissionViewSet, 'permission') urlpatterns = router.urls drf-extensions-0.6.0/tests_app/tests/functional/routers/extended_default_router/views.py000066400000000000000000000010651361353156700321250ustar00rootroot00000000000000from rest_framework.viewsets import ModelViewSet from rest_framework_extensions.mixins import NestedViewSetMixin from .models import ( DefaultRouterUserModel, DefaultRouterGroupModel, DefaultRouterPermissionModel, ) class UserViewSet(NestedViewSetMixin, ModelViewSet): queryset = DefaultRouterUserModel.objects.all() class GroupViewSet(NestedViewSetMixin, ModelViewSet): queryset = DefaultRouterGroupModel.objects.all() class PermissionViewSet(NestedViewSetMixin, ModelViewSet): queryset = DefaultRouterPermissionModel.objects.all() drf-extensions-0.6.0/tests_app/tests/functional/routers/models.py000066400000000000000000000002321361353156700253420ustar00rootroot00000000000000from django.db import models class RouterTestModel(models.Model): uuid = models.CharField(max_length=20) text = models.CharField(max_length=200)drf-extensions-0.6.0/tests_app/tests/functional/routers/nested_router_mixin/000077500000000000000000000000001361353156700275765ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/routers/nested_router_mixin/__init__.py000066400000000000000000000000011361353156700316760ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/functional/routers/nested_router_mixin/models.py000066400000000000000000000022071361353156700314340ustar00rootroot00000000000000from django.db import models from django.contrib.contenttypes.fields import GenericForeignKey class NestedRouterMixinUserModel(models.Model): email = models.EmailField(blank=True, null=True) name = models.CharField(max_length=10) groups = models.ManyToManyField( 'NestedRouterMixinGroupModel', related_name='user_groups') class NestedRouterMixinGroupModel(models.Model): name = models.CharField(max_length=10) permissions = models.ManyToManyField('NestedRouterMixinPermissionModel') class NestedRouterMixinPermissionModel(models.Model): name = models.CharField(max_length=10) class NestedRouterMixinTaskModel(models.Model): title = models.CharField(max_length=30) class NestedRouterMixinBookModel(models.Model): title = models.CharField(max_length=30) class NestedRouterMixinCommentModel(models.Model): content_type = models.ForeignKey( "contenttypes.ContentType", blank=True, null=True, on_delete=models.CASCADE, ) object_id = models.PositiveIntegerField(blank=True, null=True) content_object = GenericForeignKey() text = models.CharField(max_length=30) drf-extensions-0.6.0/tests_app/tests/functional/routers/nested_router_mixin/serializers.py000066400000000000000000000025621361353156700325110ustar00rootroot00000000000000from rest_framework import serializers from .models import ( NestedRouterMixinUserModel as UserModel, NestedRouterMixinGroupModel as GroupModel, NestedRouterMixinPermissionModel as PermissionModel, NestedRouterMixinTaskModel as TaskModel, NestedRouterMixinBookModel as BookModel, NestedRouterMixinCommentModel as CommentModel, ) class UserSerializer(serializers.ModelSerializer): class Meta: model = UserModel fields = ( 'id', 'name' ) class GroupSerializer(serializers.ModelSerializer): class Meta: model = GroupModel fields = ( 'id', 'name' ) class PermissionSerializer(serializers.ModelSerializer): class Meta: model = PermissionModel fields = ( 'id', 'name' ) class TaskSerializer(serializers.ModelSerializer): class Meta: model = TaskModel fields = ( 'id', 'title' ) class BookSerializer(serializers.ModelSerializer): class Meta: model = BookModel fields = ( 'id', 'title' ) class CommentSerializer(serializers.ModelSerializer): class Meta: model = CommentModel fields = ( 'id', 'content_type', 'object_id', 'text' )drf-extensions-0.6.0/tests_app/tests/functional/routers/nested_router_mixin/tests.py000066400000000000000000000446111361353156700313200ustar00rootroot00000000000000from django.test import override_settings from rest_framework_extensions.test import APITestCase from .models import ( NestedRouterMixinUserModel as UserModel, NestedRouterMixinGroupModel as GroupModel, NestedRouterMixinPermissionModel as PermissionModel, NestedRouterMixinTaskModel as TaskModel, NestedRouterMixinBookModel as BookModel, NestedRouterMixinCommentModel as CommentModel ) @override_settings(ROOT_URLCONF='tests_app.tests.functional.routers.nested_router_mixin.urls') class NestedRouterMixinTestBehaviourBase(APITestCase): def setUp(self): self.users = { 'vova': UserModel.objects.create(id=1, name='vova'), 'gena': UserModel.objects.create(id=2, name='gena'), } self.groups = { 'users': GroupModel.objects.create(id=3, name='users'), 'admins': GroupModel.objects.create(id=4, name='admins'), 'super_admins': GroupModel.objects.create(id=5, name='super_admins'), } self.permissions = { 'read': PermissionModel.objects.create(id=6, name='read'), 'update': PermissionModel.objects.create(id=7, name='update'), 'delete': PermissionModel.objects.create(id=8, name='delete'), } # add permissions to groups self.groups['users'].permissions.set([ self.permissions['read'] ]) self.groups['admins'].permissions.set([ self.permissions['read'], self.permissions['update'], ]) self.groups['super_admins'].permissions.set([ self.permissions['read'], self.permissions['update'], self.permissions['delete'], ]) # add groups to users self.users['vova'].groups.set([ self.groups['users'] ]) self.users['gena'].groups.set([ self.groups['admins'], self.groups['super_admins'], ]) class NestedRouterMixinTestBehaviour__main_routes(NestedRouterMixinTestBehaviourBase): def test_users(self): response = self.client.get('/users/') self.assertEqual(response.status_code, 200) expected = [ { 'id': self.users['vova'].id, 'name': self.users['vova'].name }, { 'id': self.users['gena'].id, 'name': self.users['gena'].name }, ] self.assertEqual(response.data, expected) def test_users_detail(self): url = '/users/{0}/'.format(self.users['gena'].id) response = self.client.get(url) self.assertEqual(response.status_code, 200) expected = { 'id': self.users['gena'].id, 'name': self.users['gena'].name } self.assertEqual(response.data, expected) def test_users_groups(self): url = '/users/{0}/groups/'.format(self.users['gena'].id) response = self.client.get(url) self.assertEqual(response.status_code, 200) expected = [ { 'id': self.groups['admins'].id, 'name': self.groups['admins'].name }, { 'id': self.groups['super_admins'].id, 'name': self.groups['super_admins'].name } ] msg = 'Groups should be filtered by user' self.assertEqual(response.data, expected, msg=msg) def test_users_groups_detail(self): url = '/users/{user_pk}/groups/{group_pk}/'.format( user_pk=self.users['gena'].id, group_pk=self.groups['admins'].id ) response = self.client.get(url) self.assertEqual(response.status_code, 200) expected = { 'id': self.groups['admins'].id, 'name': self.groups['admins'].name } self.assertEqual(response.data, expected) def test_users_groups_detail__if_user_has_no_such_group(self): url = '/users/{user_pk}/groups/{group_pk}/'.format( user_pk=self.users['gena'].id, group_pk=self.groups['users'].id ) response = self.client.get(url) msg = 'If user has no requested group it should return 404' self.assertEqual(response.status_code, 404, msg=msg) def test_simple_groups(self): response = self.client.get('/groups/') self.assertEqual(response.status_code, 200) expected = [ { 'id': self.groups['users'].id, 'name': self.groups['users'].name }, { 'id': self.groups['admins'].id, 'name': self.groups['admins'].name }, { 'id': self.groups['super_admins'].id, 'name': self.groups['super_admins'].name }, ] self.assertEqual(response.data, expected) def test_simple_permissions(self): response = self.client.get('/permissions/') self.assertEqual(response.status_code, 200) expected = [ { 'id': self.permissions['read'].id, 'name': self.permissions['read'].name }, { 'id': self.permissions['update'].id, 'name': self.permissions['update'].name }, { 'id': self.permissions['delete'].id, 'name': self.permissions['delete'].name }, ] self.assertEqual(response.data, expected) class NestedRouterMixinTestBehaviour__register_on_one_depth(NestedRouterMixinTestBehaviourBase): def test_permissions(self): response = self.client.get('/permissions/') self.assertEqual(response.status_code, 200) expected = [ { 'id': self.permissions['read'].id, 'name': self.permissions['read'].name }, { 'id': self.permissions['update'].id, 'name': self.permissions['update'].name }, { 'id': self.permissions['delete'].id, 'name': self.permissions['delete'].name }, ] self.assertEqual(response.data, expected) def test_permissions_detail(self): url = '/permissions/{0}/'.format(self.permissions['read'].id) response = self.client.get(url) self.assertEqual(response.status_code, 200) expected = { 'id': self.permissions['read'].id, 'name': self.permissions['read'].name } self.assertEqual(response.data, expected) def test_permissions_groups(self): url = '/permissions/{0}/groups/'.format(self.permissions['update'].id) response = self.client.get(url) self.assertEqual(response.status_code, 200) expected = [ { 'id': self.groups['admins'].id, 'name': self.groups['admins'].name }, { 'id': self.groups['super_admins'].id, 'name': self.groups['super_admins'].name }, ] msg = 'Groups should be filtered by permission' self.assertEqual(response.data, expected, msg=msg) def test_permissions_users(self): url = '/permissions/{0}/users/'.format(self.permissions['delete'].id) response = self.client.get(url) self.assertEqual(response.status_code, 200) expected = [ { 'id': self.users['gena'].id, 'name': self.users['gena'].name }, ] msg = 'Users should be filtered by group permissions' self.assertEqual(response.data, expected, msg=msg) class NestedRouterMixinTestBehaviour__actions_and_links(NestedRouterMixinTestBehaviourBase): def test_users_list_action(self): response = self.client.post('/users/users-list-action/') self.assertEqual(response.status_code, 200) self.assertEqual(response.data, 'users list action') def test_users_action(self): url = '/users/{0}/users-action/'.format(self.users['gena'].id) response = self.client.post(url) self.assertEqual(response.status_code, 200) self.assertEqual(response.data, 'users action') def test_groups_list_link(self): url = '/groups/groups-list-link/' response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(response.data, 'groups list link') def test_groups_link(self): url = '/groups/{0}/groups-link/'.format( self.groups['admins'].id, ) response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(response.data, 'groups link') def test_users_groups_list_link(self): url = '/users/{0}/groups/groups-list-link/'.format(self.users['gena'].id) response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(response.data, 'groups list link') def test_permissions_list_action(self): url = '/permissions/permissions-list-action/' response = self.client.post(url) self.assertEqual(response.status_code, 200) self.assertEqual(response.data, 'permissions list action') def test_permissions_action(self): url = '/permissions/{0}/permissions-action/'.format( self.permissions['delete'].id, ) response = self.client.post(url) self.assertEqual(response.status_code, 200) self.assertEqual(response.data, 'permissions action') def test_users_groups_link(self): url = '/users/{0}/groups/{1}/groups-link/'.format( self.users['gena'].id, self.groups['admins'].id, ) response = self.client.get(url) self.assertEqual(response.status_code, 200) self.assertEqual(response.data, 'groups link') def test_users_groups_permissions_list_action(self): url = '/users/{0}/groups/{1}/permissions/permissions-list-action/'.format( self.users['gena'].id, self.groups['admins'].id, ) response = self.client.post(url) self.assertEqual(response.status_code, 200) self.assertEqual(response.data, 'permissions list action') def test_users_groups_permissions_action(self): url = '/users/{0}/groups/{1}/permissions/{2}/permissions-action/'.format( self.users['gena'].id, self.groups['admins'].id, self.permissions['delete'].id, ) response = self.client.post(url) self.assertEqual(response.status_code, 200) self.assertEqual(response.data, 'permissions action') @override_settings(ROOT_URLCONF='tests_app.tests.functional.routers.nested_router_mixin.urls_generic_relations') class NestedRouterMixinTestBehaviour__generic_relations(APITestCase): def setUp(self): self.tasks = { 'one': TaskModel.objects.create(id=1, title='Task one'), 'two': TaskModel.objects.create(id=2, title='Task two'), } self.books = { 'one': BookModel.objects.create(id=1, title='Book one'), 'two': BookModel.objects.create(id=2, title='Book two'), } self.comments = { 'for_task_one': CommentModel.objects.create( id=1, content_object=self.tasks['one'], text=u'Comment for task one' ), 'for_task_two': CommentModel.objects.create( id=2, content_object=self.tasks['two'], text=u'Comment for task two' ), 'for_book_one': CommentModel.objects.create( id=3, content_object=self.books['one'], text=u'Comment for book one' ), 'for_book_two': CommentModel.objects.create( id=4, content_object=self.books['two'], text=u'Comment for book two' ), } def assertResult(self, response, result): self.assertEqual(response.status_code, 200) self.assertEqual(response.data, result) def test_comments_for_tasks(self): url = '/tasks/{0}/comments/'.format( self.tasks['one'].id, ) response = self.client.get(url) self.assertResult(response, [ { 'id': self.comments['for_task_one'].id, 'content_type': self.comments['for_task_one'].content_type.id, 'object_id': self.comments['for_task_one'].object_id, 'text': self.comments['for_task_one'].text, } ]) url = '/tasks/{0}/comments/'.format( self.tasks['two'].id, ) response = self.client.get(url) self.assertResult(response, [ { 'id': self.comments['for_task_two'].id, 'content_type': self.comments['for_task_two'].content_type.id, 'object_id': self.comments['for_task_two'].object_id, 'text': self.comments['for_task_two'].text, } ]) def test_comments_for_books(self): url = '/books/{0}/comments/'.format( self.books['one'].id, ) response = self.client.get(url) self.assertResult(response, [ { 'id': self.comments['for_book_one'].id, 'content_type': self.comments['for_book_one'].content_type.id, 'object_id': self.comments['for_book_one'].object_id, 'text': self.comments['for_book_one'].text, } ]) url = '/books/{0}/comments/'.format( self.books['two'].id, ) response = self.client.get(url) self.assertResult(response, [ { 'id': self.comments['for_book_two'].id, 'content_type': self.comments['for_book_two'].content_type.id, 'object_id': self.comments['for_book_two'].object_id, 'text': self.comments['for_book_two'].text, } ]) @override_settings(ROOT_URLCONF='tests_app.tests.functional.routers.nested_router_mixin.urls_parent_viewset_lookup') class NestedRouterMixinTestBehaviour__parent_viewset_lookup(APITestCase): def setUp(self): self.users = { 'vova': UserModel.objects.create(id=1, name='vova', email='vova@example.com'), 'gena': UserModel.objects.create(id=2, name='gena', email='gena@example.com'), } self.groups = { 'users': GroupModel.objects.create(id=3, name='users'), 'admins': GroupModel.objects.create(id=4, name='admins'), 'super_admins': GroupModel.objects.create(id=5, name='super_admins'), } # add groups to users self.users['vova'].groups.set([ self.groups['users'] ]) self.users['gena'].groups.set([ self.groups['admins'], self.groups['super_admins'], ]) def test_users_detail(self): url = '/users/{0}/'.format(self.users['gena'].email) response = self.client.get(url) self.assertEqual(response.status_code, 200) expected = { 'id': self.users['gena'].id, 'name': self.users['gena'].name } self.assertEqual(response.data, expected) def test_users_groups(self): url = '/users/{0}/groups/'.format(self.users['gena'].email) response = self.client.get(url) self.assertEqual(response.status_code, 200) expected = [ { 'id': self.groups['admins'].id, 'name': self.groups['admins'].name }, { 'id': self.groups['super_admins'].id, 'name': self.groups['super_admins'].name } ] msg = 'Groups should be filtered by user' self.assertEqual(response.data, expected, msg=msg) def test_users_groups_detail(self): url = '/users/{user_email}/groups/{group_pk}/'.format( user_email=self.users['gena'].email, group_pk=self.groups['admins'].id ) response = self.client.get(url) self.assertEqual(response.status_code, 200) expected = { 'id': self.groups['admins'].id, 'name': self.groups['admins'].name } self.assertEqual(response.data, expected) def test_users_groups_detail__if_user_has_no_such_group(self): url = '/users/{user_email}/groups/{group_pk}/'.format( user_email=self.users['gena'].email, group_pk=self.groups['users'].id ) response = self.client.get(url) msg = 'If user has no requested group it should return 404' self.assertEqual(response.status_code, 404, msg=msg) # class NestedRouterMixinTestBehaviour__generic_relations1(APITestCase): # router = ExtendedSimpleRouter() # # tasks route # ( # router.register(r'tasks', TaskViewSet) # .register(r'', TaskCommentViewSet, 'tasks-comment', parents_query_lookups=['object_id']) # ) # # books route # ( # router.register(r'books', BookViewSet) # .register(r'', BookCommentViewSet, 'books-comment', parents_query_lookups=['object_id']) # ) # # urls = router.urls # # def setUp(self): # self.tasks = { # 'one': TaskModel.objects.create(id=1, title='Task one'), # 'two': TaskModel.objects.create(id=2, title='Task two'), # } # self.books = { # 'one': BookModel.objects.create(id=1, title='Book one'), # 'two': BookModel.objects.create(id=2, title='Book two'), # } # self.comments = { # 'for_task_one': CommentModel.objects.create( # id=1, # content_object=self.tasks['one'], # text=u'Comment for task one' # ), # 'for_task_two': CommentModel.objects.create( # id=2, # content_object=self.tasks['two'], # text=u'Comment for task two' # ), # 'for_book_one': CommentModel.objects.create( # id=3, # content_object=self.books['one'], # text=u'Comment for book one' # ), # 'for_book_two': CommentModel.objects.create( # id=4, # content_object=self.books['two'], # text=u'Comment for book two' # ), # } # # def test_me(self): # print 'hell' drf-extensions-0.6.0/tests_app/tests/functional/routers/nested_router_mixin/urls.py000066400000000000000000000017001361353156700311330ustar00rootroot00000000000000from rest_framework_extensions.routers import ExtendedSimpleRouter from .views import ( UserViewSet, GroupViewSet, PermissionViewSet, ) router = ExtendedSimpleRouter() # main routes ( router.register(r'users', UserViewSet) .register(r'groups', GroupViewSet, 'users-group', parents_query_lookups=['user_groups']) .register(r'permissions', PermissionViewSet, 'users-groups-permission', parents_query_lookups=['group__user', 'group']) ) # register on one depth permissions_routes = router.register(r'permissions', PermissionViewSet) permissions_routes.register(r'groups', GroupViewSet, 'permissions-group', parents_query_lookups=['permissions']) permissions_routes.register(r'users', UserViewSet, 'permissions-user', parents_query_lookups=['groups__permissions']) # simple routes router.register(r'groups', GroupViewSet, 'group') router.register(r'permissions', PermissionViewSet, 'permission') urlpatterns = router.urls urls_generic_relations.py000066400000000000000000000010711361353156700346310ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/routers/nested_router_mixinfrom rest_framework_extensions.routers import ExtendedSimpleRouter from .views import ( TaskViewSet, TaskCommentViewSet, BookViewSet, BookCommentViewSet ) router = ExtendedSimpleRouter() # tasks route ( router.register(r'tasks', TaskViewSet) .register(r'comments', TaskCommentViewSet, 'tasks-comment', parents_query_lookups=['object_id']) ) # books route ( router.register(r'books', BookViewSet) .register(r'comments', BookCommentViewSet, 'books-comment', parents_query_lookups=['object_id']) ) urlpatterns = router.urls urls_parent_viewset_lookup.py000066400000000000000000000006001361353156700355620ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/functional/routers/nested_router_mixinfrom rest_framework_extensions.routers import ExtendedSimpleRouter from .views import ( UserViewSetWithEmailLookup, GroupViewSet, ) router = ExtendedSimpleRouter() # main routes ( router.register(r'users', UserViewSetWithEmailLookup) .register(r'groups', GroupViewSet, 'users-group', parents_query_lookups=['user_groups__email']) ) urlpatterns = router.urls drf-extensions-0.6.0/tests_app/tests/functional/routers/nested_router_mixin/views.py000066400000000000000000000062271361353156700313140ustar00rootroot00000000000000from django.contrib.contenttypes.models import ContentType from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.viewsets import ModelViewSet from rest_framework_extensions.mixins import NestedViewSetMixin from .models import ( NestedRouterMixinUserModel as UserModel, NestedRouterMixinGroupModel as GroupModel, NestedRouterMixinPermissionModel as PermissionModel, NestedRouterMixinTaskModel as TaskModel, NestedRouterMixinBookModel as BookModel, NestedRouterMixinCommentModel as CommentModel ) from .serializers import ( UserSerializer, GroupSerializer, PermissionSerializer, TaskSerializer, BookSerializer, CommentSerializer ) class UserViewSet(NestedViewSetMixin, ModelViewSet): queryset = UserModel.objects.all() serializer_class = UserSerializer @action(detail=False, methods=['post'], url_path='users-list-action') def users_list_action(self, request, *args, **kwargs): return Response('users list action') @action(detail=True, methods=['post'], url_path='users-action') def users_action(self, request, *args, **kwargs): return Response('users action') class GroupViewSet(NestedViewSetMixin, ModelViewSet): queryset = GroupModel.objects.all() serializer_class = GroupSerializer @action(detail=False, url_path='groups-list-link') def groups_list_link(self, request, *args, **kwargs): return Response('groups list link') @action(detail=True, url_path='groups-link') def groups_link(self, request, *args, **kwargs): return Response('groups link') class PermissionViewSet(NestedViewSetMixin, ModelViewSet): queryset = PermissionModel.objects.all() serializer_class = PermissionSerializer @action(detail=False, methods=['post'], url_path='permissions-list-action') def permissions_list_action(self, request, *args, **kwargs): return Response('permissions list action') @action(detail=True, methods=['post'], url_path='permissions-action') def permissions_action(self, request, *args, **kwargs): return Response('permissions action') class TaskViewSet(NestedViewSetMixin, ModelViewSet): queryset = TaskModel.objects.all() serializer_class = TaskSerializer class BookViewSet(NestedViewSetMixin, ModelViewSet): queryset = BookModel.objects.all() serializer_class = BookSerializer class CommentViewSet(NestedViewSetMixin, ModelViewSet): queryset = CommentModel.objects.all() serializer_class = CommentSerializer class TaskCommentViewSet(CommentViewSet): def get_queryset(self): return super().get_queryset().filter( content_type=ContentType.objects.get_for_model(TaskModel) ) class BookCommentViewSet(CommentViewSet): def get_queryset(self): return super().get_queryset().filter( content_type=ContentType.objects.get_for_model(BookModel) ) class UserViewSetWithEmailLookup(NestedViewSetMixin, ModelViewSet): queryset = UserModel.objects.all() serializer_class = UserSerializer lookup_field = 'email' lookup_value_regex = r'[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+' drf-extensions-0.6.0/tests_app/tests/functional/routers/tests.py000066400000000000000000000026301361353156700252250ustar00rootroot00000000000000from django.test import TestCase from rest_framework_extensions.routers import ExtendedSimpleRouter from tests_app.testutils import get_url_pattern_by_regex_pattern from .views import RouterViewSet class TestTrailingSlashIncluded(TestCase): def test_urls_have_trailing_slash_by_default(self): router = ExtendedSimpleRouter() router.register(r'router-viewset', RouterViewSet) urls = router.urls for exp in ['^router-viewset/$', '^router-viewset/(?P[^/.]+)/$', '^router-viewset/list_controller/$', '^router-viewset/(?P[^/.]+)/detail_controller/$']: msg = 'Should find url pattern with regexp %s' % exp self.assertIsNotNone(get_url_pattern_by_regex_pattern(urls, exp), msg=msg) class TestTrailingSlashRemoved(TestCase): def test_urls_can_have_trailing_slash_removed(self): router = ExtendedSimpleRouter(trailing_slash=False) router.register(r'router-viewset', RouterViewSet) urls = router.urls for exp in ['^router-viewset$', '^router-viewset/(?P[^/.]+)$', '^router-viewset/list_controller$', '^router-viewset/(?P[^/.]+)/detail_controller$']: msg = 'Should find url pattern with regexp %s' % exp self.assertIsNotNone(get_url_pattern_by_regex_pattern(urls, exp), msg=msg) drf-extensions-0.6.0/tests_app/tests/functional/routers/views.py000066400000000000000000000005401361353156700252160ustar00rootroot00000000000000from rest_framework import viewsets from rest_framework.decorators import action from .models import RouterTestModel class RouterViewSet(viewsets.ModelViewSet): queryset = RouterTestModel.objects.all() @action(detail=True) def detail_controller(self): pass @action(detail=False) def list_controller(self): pass drf-extensions-0.6.0/tests_app/tests/unit/000077500000000000000000000000001361353156700206225ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/__init__.py000066400000000000000000000000001361353156700227210ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/_etag/000077500000000000000000000000001361353156700217015ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/_etag/__init__.py000066400000000000000000000000001361353156700240000ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/_etag/decorators/000077500000000000000000000000001361353156700240465ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/_etag/decorators/__init__.py000066400000000000000000000000011361353156700261460ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/unit/_etag/decorators/tests.py000066400000000000000000000675461361353156700256040ustar00rootroot00000000000000from django.test import TestCase from django.utils.http import quote_etag from rest_framework import views from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import SAFE_METHODS from rest_framework_extensions.exceptions import PreconditionRequiredException from rest_framework_extensions.etag.decorators import (etag, api_etag) from rest_framework_extensions.test import APIRequestFactory from rest_framework_extensions.utils import prepare_header_name from tests_app.testutils import ( override_extensions_api_settings, ) factory = APIRequestFactory() UNSAFE_METHODS = ('POST', 'PUT', 'DELETE', 'PATCH') def dummy_api_etag_func(**kwargs): return 'hello' def default_etag_func(**kwargs): return 'hello' @override_extensions_api_settings(DEFAULT_ETAG_FUNC=default_etag_func) class ETAGProcessorTest(TestCase): def setUp(self): self.request = factory.get('') def test_should_use_etag_func_from_settings_if_it_is_not_specified(self): etag_decorator = etag() self.assertEqual(etag_decorator.etag_func, default_etag_func) def test_should_add_default_etag_value(self): class TestView(views.APIView): @etag() def get(self, request, *args, **kwargs): return Response('Response from method') view_instance = TestView() response = view_instance.get(request=self.request) expected_etag_value = default_etag_func() self.assertEqual(response.get('Etag'), quote_etag(expected_etag_value)) self.assertEqual(response.data, 'Response from method') def test_should_not_change_existing_etag_value(self): class TestView(views.APIView): @etag() def get(self, request, *args, **kwargs): return Response('Response from method', headers={'etag': 'hello'}) view_instance = TestView() response = view_instance.get(request=self.request) expected_etag_value = 'hello' self.assertEqual(response.get('Etag'), expected_etag_value) self.assertEqual(response.data, 'Response from method') def test_should_not_change_existing_etag_value__even_if_it_is_empty(self): class TestView(views.APIView): @etag() def get(self, request, *args, **kwargs): return Response('Response from method', headers={'etag': ''}) view_instance = TestView() response = view_instance.get(request=self.request) expected_etag_value = '' self.assertEqual(response.get('Etag'), expected_etag_value) self.assertEqual(response.data, 'Response from method') def test_should_use_custom_func_if_it_is_defined(self): def calculate_etag(**kwargs): return 'Custom etag' class TestView(views.APIView): @etag(calculate_etag) def get(self, request, *args, **kwargs): return Response('Response from method') view_instance = TestView() response = view_instance.get(request=self.request) expected_etag_value = quote_etag('Custom etag') self.assertEqual(response.get('Etag'), expected_etag_value) self.assertEqual(response.data, 'Response from method') def test_should_use_custom_method_from_view_if__etag_func__is_string(self): used_kwargs = {} class TestView(views.APIView): @etag('calculate_etag') def get(self, request, *args, **kwargs): return Response('Response from method') def calculate_etag(self, **kwargs): used_kwargs.update(kwargs) used_kwargs.update({'self': self}) return 'Custom etag' view_instance = TestView() response = view_instance.get(request=self.request) expected_etag_value = quote_etag('Custom etag') self.assertEqual(response.get('Etag'), expected_etag_value) self.assertEqual(response.data, 'Response from method') self.assertEqual(used_kwargs['self'], used_kwargs['view_instance']) def test_custom_func_arguments(self): called_with_kwargs = {} def calculate_etag(**kwargs): called_with_kwargs.update(kwargs) return 'Custom etag' class TestView(views.APIView): @etag(calculate_etag) def get(self, request, *args, **kwargs): return Response('Response from method') view_instance = TestView() view_instance.get(self.request, 'hello', hello='world') self.assertEqual(called_with_kwargs.get('view_instance'), view_instance) # self.assertEqual(called_with_kwargs.get('view_method'), view_instance.get) # todo: test me self.assertEqual(called_with_kwargs.get('args'), ('hello',)) self.assertEqual(called_with_kwargs.get('kwargs'), {'hello': 'world'}) class ETAGProcessorTestBehavior_rebuild_after_method_evaluation(TestCase): def setUp(self): self.request = factory.get('') def test_should_not__rebuild_after_method_evaluation__by_default(self): call_stack = [] def calculate_etag(**kwargs): call_stack.append(1) return ''.join([str(i) for i in call_stack]) class TestView(views.APIView): @etag(calculate_etag) def get(self, request, *args, **kwargs): return Response('Response from method') view_instance = TestView() response = view_instance.get(self.request) expected_etag_value = quote_etag('1') self.assertEqual(response.get('Etag'), expected_etag_value) self.assertEqual(response.data, 'Response from method') def test_should__rebuild_after_method_evaluation__if_it_asked(self): call_stack = [] def calculate_etag(**kwargs): call_stack.append(1) return ''.join([str(i) for i in call_stack]) class TestView(views.APIView): @etag(calculate_etag, rebuild_after_method_evaluation=True) def get(self, request, *args, **kwargs): return Response('Response from method') view_instance = TestView() response = view_instance.get(self.request) expected_etag_value = quote_etag('11') self.assertEqual(response.get('Etag'), expected_etag_value) self.assertEqual(response.data, 'Response from method') class ETAGProcessorTestBehaviorMixin: def setUp(self): def calculate_etag(**kwargs): return '123' class TestView(views.APIView): @etag(calculate_etag) def get(self, request, *args, **kwargs): return Response('Response from method') self.view_instance = TestView() self.expected_etag_value = quote_etag(calculate_etag()) def run_for_methods(self, methods, condition_failed_status): for method in methods: for exp in self.experiments: headers = { prepare_header_name(self.header_name): exp['header_value'] } request = getattr(factory, method.lower())('', **headers) response = self.view_instance.get(request) base_msg = ( 'For "{method}" and {header_name} value {header_value} condition should' ).format( method=method, header_name=self.header_name, header_value=exp['header_value'], ) if exp['should_fail']: msg = base_msg + ( ' fail and response must be returned with {condition_failed_status} status. ' 'But it is {response_status}' ).format(condition_failed_status=condition_failed_status, response_status=response.status_code) self.assertEqual(response.status_code, condition_failed_status, msg=msg) msg = base_msg + ' fail and response must be empty' self.assertEqual(response.data, None, msg=msg) msg = ( 'If precondition failed, then Etag must always be added to response. But it is {0}' ).format(response.get('Etag')) self.assertEqual(response.get('Etag'), self.expected_etag_value, msg=msg) else: msg = base_msg + ( ' not fail and response must be returned with 200 status. ' 'But it is "{response_status}"' ).format(response_status=response.status_code) self.assertEqual(response.status_code, status.HTTP_200_OK, msg=msg) msg = base_msg + 'not fail and response must be filled' self.assertEqual(response.data, 'Response from method', msg=msg) self.assertEqual(response.get('Etag'), self.expected_etag_value, msg=msg) class ETAGProcessorTestBehavior_if_none_match(ETAGProcessorTestBehaviorMixin, TestCase): def setUp(self): super().setUp() self.header_name = 'if-none-match' self.experiments = [ { 'header_value': '123', 'should_fail': True }, { 'header_value': '"123"', 'should_fail': True }, { 'header_value': '321', 'should_fail': False }, { 'header_value': '"321"', 'should_fail': False }, { 'header_value': '"1234"', 'should_fail': False }, { 'header_value': '"321" "123"', 'should_fail': True }, { 'header_value': '321 "123"', 'should_fail': True }, { 'header_value': '*', 'should_fail': True }, { 'header_value': '"*"', 'should_fail': True }, { 'header_value': '321 "*"', 'should_fail': True }, ] def test_for_safe_methods(self): self.run_for_methods(SAFE_METHODS, condition_failed_status=status.HTTP_304_NOT_MODIFIED) def test_for_unsafe_methods(self): self.run_for_methods(UNSAFE_METHODS, condition_failed_status=status.HTTP_412_PRECONDITION_FAILED) class ETAGProcessorTestBehavior_if_match(ETAGProcessorTestBehaviorMixin, TestCase): def setUp(self): super().setUp() self.header_name = 'if-match' self.experiments = [ { 'header_value': '123', 'should_fail': False }, { 'header_value': '"123"', 'should_fail': False }, { 'header_value': '321', 'should_fail': True }, { 'header_value': '"321"', 'should_fail': True }, { 'header_value': '"1234"', 'should_fail': True }, { 'header_value': '"321" "123"', 'should_fail': False }, { 'header_value': '321 "123"', 'should_fail': False }, { 'header_value': '*', 'should_fail': False }, { 'header_value': '"*"', 'should_fail': False }, { 'header_value': '321 "*"', 'should_fail': False }, ] def test_for_all_methods(self): self.run_for_methods( tuple(SAFE_METHODS) + UNSAFE_METHODS, condition_failed_status=status.HTTP_412_PRECONDITION_FAILED ) class APIETAGProcessorTest(TestCase): """Unit test cases for the APIETAGProcessor and decorator functionality.""" def setUp(self): self.request = factory.get('') def test_should_raise_assertion_error_if_etag_func_not_specified(self): with self.assertRaises(AssertionError): api_etag() def test_should_raise_assertion_error_if_etag_func_not_specified_decorator(self): with self.assertRaises(AssertionError): class View(views.APIView): @api_etag() def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) def test_should_raise_assertion_error_if_precondition_map_not_a_dict(self): with self.assertRaises(AssertionError): api_etag(etag_func=dummy_api_etag_func, precondition_map=['header-name']) def test_should_raise_assertion_error_if_precondition_map_not_a_dict_decorator(self): with self.assertRaises(AssertionError): class View(views.APIView): @api_etag(dummy_api_etag_func, precondition_map=['header-name']) def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) def test_should_add_object_etag_value(self): class TestView(views.APIView): @api_etag(dummy_api_etag_func) def get(self, request, *args, **kwargs): return Response('Response from GET method') view_instance = TestView() response = view_instance.get(request=self.request) expected_etag_value = dummy_api_etag_func() self.assertEqual(response.get('Etag'), quote_etag(expected_etag_value)) self.assertEqual(response.data, 'Response from GET method') def test_should_add_object_etag_value_empty_precondition_map_decorator(self): class TestView(views.APIView): @api_etag(dummy_api_etag_func, precondition_map={}) def get(self, request, *args, **kwargs): return Response('Response from GET method') view_instance = TestView() response = view_instance.get(request=self.request) expected_etag_value = dummy_api_etag_func() self.assertEqual(response.get('Etag'), quote_etag(expected_etag_value)) self.assertEqual(response.data, 'Response from GET method') def test_should_add_object_etag_value_default_precondition_map_decorator(self): class TestView(views.APIView): @api_etag(dummy_api_etag_func) def get(self, request, *args, **kwargs): return Response('Response from GET method') view_instance = TestView() response = view_instance.get(request=self.request) expected_etag_value = dummy_api_etag_func() self.assertEqual(response.get('Etag'), quote_etag(expected_etag_value)) self.assertEqual(response.data, 'Response from GET method') def test_should_require_precondition_decorator_unsafe_methods_empty(self): class TestView(views.APIView): @api_etag(dummy_api_etag_func, precondition_map={}) def put(self, request, *args, **kwargs): return Response('Response from PUT method') @api_etag(dummy_api_etag_func, precondition_map={}) def patch(self, request, *args, **kwargs): return Response('Response from PATCH method') @api_etag(dummy_api_etag_func, precondition_map={}) def delete(self, request, *args, **kwargs): return Response('Response from DELETE method', status=status.HTTP_204_NO_CONTENT) view_instance = TestView() response = view_instance.put(request=factory.put('')) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, 'Response from PUT method') response = view_instance.patch(request=factory.patch('')) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, 'Response from PATCH method') response = view_instance.delete(request=factory.delete('')) self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertEqual(response.data, 'Response from DELETE method') def test_should_require_precondition_decorator_unsafe_methods_explicit(self): class TestView(views.APIView): @api_etag(dummy_api_etag_func, precondition_map={'PUT': ['If-Match']}) def put(self, request, *args, **kwargs): return Response('Response from PUT method') @api_etag(dummy_api_etag_func, precondition_map={'PATCH': ['If-Match']}) def patch(self, request, *args, **kwargs): return Response('Response from PATCH method') @api_etag(dummy_api_etag_func, precondition_map={'DELETE': ['If-Match']}) def delete(self, request, *args, **kwargs): return Response('Response from DELETE method', status=status.HTTP_204_NO_CONTENT) view_instance = TestView() with self.assertRaises(PreconditionRequiredException) as cm: view_instance.put(request=factory.put('')) self.assertEqual(cm.exception.status_code, status.HTTP_428_PRECONDITION_REQUIRED) self.assertIsNotNone(cm.exception.detail) with self.assertRaises(PreconditionRequiredException) as cm: view_instance.patch(request=factory.patch('')) self.assertEqual(cm.exception.status_code, status.HTTP_428_PRECONDITION_REQUIRED) self.assertIsNotNone(cm.exception.detail) with self.assertRaises(PreconditionRequiredException) as cm: view_instance.delete(request=factory.delete('')) self.assertEqual(cm.exception.status_code, status.HTTP_428_PRECONDITION_REQUIRED) self.assertIsNotNone(cm.exception.detail) def test_precondition_decorator_unsafe_methods_if_none_match(self): def dummy_etag_func(**kwargs): return 'some_etag' class TestView(views.APIView): @api_etag(dummy_etag_func) def put(self, request, *args, **kwargs): return Response('Response from PUT method') @api_etag(dummy_etag_func) def patch(self, request, *args, **kwargs): return Response('Response from PATCH method') @api_etag(dummy_etag_func) def delete(self, request, *args, **kwargs): return Response('Response from DELETE method', status=status.HTTP_204_NO_CONTENT) headers = { prepare_header_name('if-none-match'): 'some_etag' } view_instance = TestView() with self.assertRaises(PreconditionRequiredException) as cm: view_instance.put(request=factory.put('', **headers)) self.assertEqual(cm.exception.status_code, status.HTTP_428_PRECONDITION_REQUIRED) self.assertIsNotNone(cm.exception.detail) with self.assertRaises(PreconditionRequiredException) as cm: view_instance.patch(request=factory.patch('', **headers)) self.assertEqual(cm.exception.status_code, status.HTTP_428_PRECONDITION_REQUIRED) self.assertIsNotNone(cm.exception.detail) with self.assertRaises(PreconditionRequiredException) as cm: view_instance.delete(request=factory.delete('', **headers)) self.assertEqual(cm.exception.status_code, status.HTTP_428_PRECONDITION_REQUIRED) self.assertIsNotNone(cm.exception.detail) def test_should_require_precondition_decorator_unsafe_methods_default(self): class TestView(views.APIView): @api_etag(dummy_api_etag_func) def put(self, request, *args, **kwargs): return Response('Response from PUT method') @api_etag(dummy_api_etag_func) def patch(self, request, *args, **kwargs): return Response('Response from PATCH method') @api_etag(dummy_api_etag_func) def delete(self, request, *args, **kwargs): return Response('Response from DELETE method', status=status.HTTP_204_NO_CONTENT) view_instance = TestView() with self.assertRaises(PreconditionRequiredException) as cm: view_instance.put(request=factory.put('')) self.assertEqual(cm.exception.status_code, status.HTTP_428_PRECONDITION_REQUIRED) self.assertIsNotNone(cm.exception.detail) with self.assertRaises(PreconditionRequiredException) as cm: view_instance.patch(request=factory.patch('')) self.assertEqual(cm.exception.status_code, status.HTTP_428_PRECONDITION_REQUIRED) self.assertIsNotNone(cm.exception.detail) with self.assertRaises(PreconditionRequiredException) as cm: view_instance.delete(request=factory.delete('')) self.assertEqual(cm.exception.status_code, status.HTTP_428_PRECONDITION_REQUIRED) self.assertIsNotNone(cm.exception.detail) class APIETAGProcessorTestBehaviorMixin: def setUp(self): def calculate_etag(**kwargs): return '123' class TestView(views.APIView): @api_etag(calculate_etag) def head(self, request, *args, **kwargs): return Response('Response from HEAD method') @api_etag(calculate_etag) def options(self, request, *args, **kwargs): return Response('Response from OPTIONS method') @api_etag(calculate_etag, precondition_map={}) def post(self, request, *args, **kwargs): return Response('Response from POST method', status=status.HTTP_201_CREATED) @api_etag(calculate_etag) def get(self, request, *args, **kwargs): return Response('Response from GET method') @api_etag(calculate_etag) def put(self, request, *args, **kwargs): return Response('Response from PUT method') @api_etag(calculate_etag) def patch(self, request, *args, **kwargs): return Response('Response from PATCH method') @api_etag(calculate_etag) def delete(self, request, *args, **kwargs): return Response('Response from DELETE method', status=status.HTTP_204_NO_CONTENT) self.view_instance = TestView() self.expected_etag_value = quote_etag(calculate_etag()) def run_for_methods(self, methods, condition_failed_status, experiments=None): for method in methods: if experiments is None: experiments = self.experiments for exp in experiments: headers = { prepare_header_name(self.header_name): exp['header_value'] } request = getattr(factory, method.lower())('', **headers) response = getattr(self.view_instance, method.lower())(request) base_msg = ( 'For "{method}" and {header_name} value {header_value} condition should' ).format( method=method, header_name=self.header_name, header_value=exp['header_value'], ) if exp['should_fail']: msg = base_msg + ( ' fail and response must be returned with {condition_failed_status} status. ' 'But it is {response_status}' ).format(condition_failed_status=condition_failed_status, response_status=response.status_code) self.assertEqual(response.status_code, condition_failed_status, msg=msg) msg = base_msg + ' fail and response must be empty' self.assertEqual(response.data, None, msg=msg) msg = ( 'If precondition failed, then Etag must always be added to response. But it is {0}' ).format(response.get('Etag')) self.assertEqual(response.get('Etag'), self.expected_etag_value, msg=msg) else: if method.lower() == 'delete': success_status = status.HTTP_204_NO_CONTENT elif method.lower() == 'post': success_status = status.HTTP_201_CREATED else: success_status = status.HTTP_200_OK msg = base_msg + ( ' not fail and response must be returned with %s status. ' 'But it is "{response_status}"' ).format(success_status, response_status=response.status_code) self.assertEqual(response.status_code, success_status, msg=msg) msg = base_msg + 'not fail and response must be filled' self.assertEqual(response.data, 'Response from %s method' % method.upper(), msg=msg) self.assertEqual(response.get('Etag'), self.expected_etag_value, msg=msg) class APIETAGProcessorTestBehavior_if_match(APIETAGProcessorTestBehaviorMixin, TestCase): def setUp(self): super().setUp() self.header_name = 'if-match' self.experiments = [ { 'header_value': '123', 'should_fail': False }, { 'header_value': '"123"', 'should_fail': False }, { 'header_value': '321', 'should_fail': True }, { 'header_value': '"321"', 'should_fail': True }, { 'header_value': '"1234"', 'should_fail': True }, { 'header_value': '"321" "123"', 'should_fail': False }, { 'header_value': '321 "123"', 'should_fail': False }, { 'header_value': '*', 'should_fail': False }, { 'header_value': '"*"', 'should_fail': False }, { 'header_value': '321 "*"', 'should_fail': False }, ] def test_for_all_methods(self): self.run_for_methods( tuple(SAFE_METHODS) + UNSAFE_METHODS, condition_failed_status=status.HTTP_412_PRECONDITION_FAILED ) class APIETAGProcessorTestBehavior_if_none_match(APIETAGProcessorTestBehaviorMixin, TestCase): def setUp(self): super().setUp() self.header_name = 'if-none-match' self.experiments = [ { 'header_value': '123', 'should_fail': True }, { 'header_value': '"123"', 'should_fail': True }, { 'header_value': '321', 'should_fail': False }, { 'header_value': '"321"', 'should_fail': False }, { 'header_value': '"1234"', 'should_fail': False }, { 'header_value': '"321" "123"', 'should_fail': True }, { 'header_value': '321 "123"', 'should_fail': True }, { 'header_value': '*', 'should_fail': True }, { 'header_value': '"*"', 'should_fail': True }, { 'header_value': '321 "*"', 'should_fail': True }, ] def test_for_safe_methods(self): self.run_for_methods(SAFE_METHODS, condition_failed_status=status.HTTP_304_NOT_MODIFIED) # NB: We don't test the unsafe methods here, since the PreconditionRequiredException would require us to hack the # runner method. However, we tested the exceptions in the APIETAGProcessorTest class. drf-extensions-0.6.0/tests_app/tests/unit/cache/000077500000000000000000000000001361353156700216655ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/cache/__init__.py000066400000000000000000000000001361353156700237640ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/cache/decorators/000077500000000000000000000000001361353156700240325ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/cache/decorators/__init__.py000066400000000000000000000000011361353156700261320ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/unit/cache/decorators/tests.py000066400000000000000000000315311361353156700255510ustar00rootroot00000000000000from django.core.cache import caches from django.test import TestCase from mock import Mock, patch from rest_framework import views from rest_framework.response import Response from rest_framework_extensions.cache.decorators import cache_response from rest_framework_extensions.settings import extensions_api_settings from rest_framework_extensions.test import APIRequestFactory from tests_app.testutils import override_extensions_api_settings factory = APIRequestFactory() class CacheResponseTest(TestCase): def setUp(self): super().setUp() self.request = factory.get('') self.cache = caches[extensions_api_settings.DEFAULT_USE_CACHE] def test_should_return_response_if_it_is_not_in_cache(self): class TestView(views.APIView): @cache_response() def get(self, request, *args, **kwargs): return Response('Response from method 1') view_instance = TestView() response = view_instance.dispatch(request=self.request) self.assertEqual(response.data, 'Response from method 1') self.assertEqual(type(response), Response) @override_extensions_api_settings(DEFAULT_CACHE_KEY_FUNC=Mock(return_value='cache_response_key')) def test_should_store_response_in_cache_by_key_function_which_specified_in_settings(self): class TestView(views.APIView): @cache_response() def get(self, request, *args, **kwargs): return Response('Response from method 2') view_instance = TestView() response = view_instance.dispatch(request=self.request) self.assertEqual(self.cache.get('cache_response_key') [0], response.content) self.assertEqual(type(response), Response) def test_should_store_response_in_cache_by_key_function_which_specified_in_arguments(self): def key_func(*args, **kwargs): return 'cache_response_key_from_func' class TestView(views.APIView): @cache_response(key_func=key_func) def get(self, request, *args, **kwargs): return Response('Response from method 3') view_instance = TestView() response = view_instance.dispatch(request=self.request) self.assertEqual(self.cache.get( 'cache_response_key_from_func')[0], response.content) self.assertEqual(type(response), Response) def test_should_store_response_in_cache_by_key_which_calculated_by_view_method__if__key_func__is_string(self): class TestView(views.APIView): @cache_response(key_func='key_func') def get(self, request, *args, **kwargs): return Response('Response from method 3') def key_func(self, *args, **kwargs): return 'cache_response_key_from_method' view_instance = TestView() response = view_instance.dispatch(request=self.request) self.assertEqual(self.cache.get( 'cache_response_key_from_method')[0], response.content) self.assertEqual(type(response), Response) def test_key_func_call_arguments(self): called_with_kwargs = {} def key_func(**kwargs): called_with_kwargs.update(kwargs) return 'cache_response_key_from_func' class TestView(views.APIView): @cache_response(key_func=key_func) def get(self, request, *args, **kwargs): return Response('Response from method 3') view_instance = TestView() response = view_instance.dispatch(self.request, 'hello', hello='world') self.assertEqual(called_with_kwargs.get( 'view_instance'), view_instance) # self.assertEqual(called_with_kwargs.get('view_method'), view_instance.get) # todo: test me self.assertEqual(called_with_kwargs.get('args'), ('hello',)) self.assertEqual(called_with_kwargs.get('kwargs'), {'hello': 'world'}) @override_extensions_api_settings( DEFAULT_CACHE_RESPONSE_TIMEOUT=100, DEFAULT_CACHE_KEY_FUNC=Mock(return_value='cache_response_key') ) def test_should_store_response_in_cache_with_timeout_from_settings(self): cache_response_decorator = cache_response() cache_response_decorator.cache.set = Mock() class TestView(views.APIView): @cache_response_decorator def get(self, request, *args, **kwargs): return Response('Response from method 4') view_instance = TestView() response = view_instance.dispatch(request=self.request) self.assertTrue( cache_response_decorator.cache.set.called, 'Cache saving should be performed') self.assertEqual( cache_response_decorator.cache.set.call_args_list[0][0][2], 100) def test_should_store_response_in_cache_with_timeout_from_arguments(self): cache_response_decorator = cache_response(timeout=3) cache_response_decorator.cache.set = Mock() class TestView(views.APIView): @cache_response_decorator def get(self, request, *args, **kwargs): return Response('Response from method 4') view_instance = TestView() response = view_instance.dispatch(request=self.request) self.assertTrue( cache_response_decorator.cache.set.called, 'Cache saving should be performed') self.assertEqual( cache_response_decorator.cache.set.call_args_list[0][0][2], 3) def test_should_store_response_in_cache_with_timeout_from_object_cache_timeout_property(self): cache_response_decorator = cache_response( timeout='object_cache_timeout') cache_response_decorator.cache.set = Mock() class TestView(views.APIView): object_cache_timeout = 20 @cache_response_decorator def get(self, request, *args, **kwargs): return Response('Response from method 4') view_instance = TestView() response = view_instance.dispatch(request=self.request) self.assertTrue( cache_response_decorator.cache.set.called, 'Cache saving should be performed') self.assertEqual( cache_response_decorator.cache.set.call_args_list[0][0][2], 20) def test_should_store_response_in_cache_with_timeout_from_list_cache_timeout_property(self): cache_response_decorator = cache_response(timeout='list_cache_timeout') cache_response_decorator.cache.set = Mock() class TestView(views.APIView): list_cache_timeout = 10 @cache_response_decorator def get(self, request, *args, **kwargs): return Response('Response from method 4') view_instance = TestView() response = view_instance.dispatch(request=self.request) self.assertTrue( cache_response_decorator.cache.set.called, 'Cache saving should be performed') self.assertEqual( cache_response_decorator.cache.set.call_args_list[0][0][2], 10) def test_should_return_response_from_cache_if_it_is_in_it(self): def key_func(**kwargs): return 'cache_response_key' class TestView(views.APIView): @cache_response(key_func=key_func) def get(self, request, *args, **kwargs): return Response(u'Response from method 4') view_instance = TestView() view_instance.headers = {} cached_response = Response('Cached response from method 4') view_instance.finalize_response( request=self.request, response=cached_response) cached_response.render() response_dict = ( cached_response.rendered_content, cached_response.status_code, cached_response._headers ) self.cache.set('cache_response_key', response_dict) response = view_instance.dispatch(request=self.request) self.assertEqual( response.content.decode('utf-8'), '"Cached response from method 4"') @override_extensions_api_settings( DEFAULT_USE_CACHE='special_cache' ) def test_should_use_cache_from_settings_by_default(self): def key_func(**kwargs): return 'cache_response_key' class TestView(views.APIView): @cache_response(key_func=key_func) def get(self, request, *args, **kwargs): return Response(u'Response from method 5') view_instance = TestView() view_instance.dispatch(request=self.request) data_from_cache = caches['special_cache'].get('cache_response_key') self.assertEqual(len(data_from_cache), 3) self.assertEqual( data_from_cache[0].decode('utf-8'), u'"Response from method 5"') @override_extensions_api_settings( DEFAULT_USE_CACHE='special_cache' ) def test_should_use_cache_from_decorator_if_it_is_specified(self): def key_func(**kwargs): return 'cache_response_key' class TestView(views.APIView): @cache_response(key_func=key_func, cache='another_special_cache') def get(self, request, *args, **kwargs): return Response(u'Response from method 6') view_instance = TestView() view_instance.dispatch(request=self.request) data_from_cache = caches['another_special_cache'].get( 'cache_response_key') self.assertEqual(len(data_from_cache), 3) self.assertEqual(data_from_cache[0].decode( 'utf-8'), u'"Response from method 6"') def test_should_reuse_cache_singleton(self): """ https://github.com/chibisov/drf-extensions/issues/26 https://docs.djangoproject.com/en/dev/topics/cache/#django.core.cache.caches """ cache_response_instance = cache_response() another_cache_response_instance = cache_response() self.assertTrue( cache_response_instance.cache is another_cache_response_instance.cache) def test_dont_cache_response_with_error_if_cache_error_false(self): cache_response_decorator = cache_response(cache_errors=False) class TestView(views.APIView): def __init__(self, status, *args, **kwargs): self.status = status super().__init__(*args, **kwargs) @cache_response_decorator def get(self, request, *args, **kwargs): return Response(status=self.status) with patch.object(cache_response_decorator.cache, 'set'): for status in (400, 500): view_instance = TestView(status=status) view_instance.dispatch(request=self.request) self.assertFalse(cache_response_decorator.cache.set.called) def test_cache_response_with_error_by_default(self): cache_response_decorator = cache_response() class TestView(views.APIView): def __init__(self, status, *args, **kwargs): self.status = status super().__init__(*args, **kwargs) @cache_response_decorator def get(self, request, *args, **kwargs): return Response(status=self.status) with patch.object(cache_response_decorator.cache, 'set'): for status in (400, 500): view_instance = TestView(status=status) view_instance.dispatch(request=self.request) self.assertTrue(cache_response_decorator.cache.set.called) @override_extensions_api_settings( DEFAULT_CACHE_ERRORS=False ) def test_should_use_cache_error_from_settings_by_default(self): self.assertFalse(cache_response().cache_errors) @override_extensions_api_settings( DEFAULT_CACHE_ERRORS=False ) def test_should_use_cache_error_from_decorator_if_it_is_specified(self): self.assertTrue(cache_response(cache_errors=True).cache_errors) def test_should_return_response_with_tuple_headers(self): def key_func(**kwargs): return 'cache_response_key' class TestView(views.APIView): @cache_response(key_func=key_func) def get(self, request, *args, **kwargs): return Response(u'') view_instance = TestView() view_instance.headers = {'Test': 'foo'} cached_response = Response(u'') view_instance.finalize_response( request=self.request, response=cached_response) cached_response.render() response_dict = ( cached_response.rendered_content, cached_response.status_code, {k: list(v) for k, v in cached_response._headers.items()} ) self.cache.set('cache_response_key', response_dict) response = view_instance.dispatch(request=self.request) self.assertTrue(all(isinstance(v, tuple) for v in response._headers.values())) self.assertEqual(response._headers['test'], ('Test', 'foo')) drf-extensions-0.6.0/tests_app/tests/unit/decorators/000077500000000000000000000000001361353156700227675ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/decorators/__init__.py000066400000000000000000000000001361353156700250660ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/decorators/tests.py000066400000000000000000000030011361353156700244750ustar00rootroot00000000000000from django.test import TestCase from rest_framework import pagination, viewsets from rest_framework_extensions.decorators import paginate class TestPaginateDecorator(TestCase): def test_empty_pagination_class(self): msg = "@paginate missing required argument: 'pagination_class'" with self.assertRaisesMessage(AssertionError, msg): @paginate() class MockGenericViewSet(viewsets.GenericViewSet): pass def test_adding_page_number_pagination(self): """ Other default pagination classes' test result will be same as this even if kwargs changed to anything. """ @paginate(pagination_class=pagination.PageNumberPagination, page_size=5, ordering='-created_at') class MockGenericViewSet(viewsets.GenericViewSet): pass assert hasattr(MockGenericViewSet, 'pagination_class') assert MockGenericViewSet.pagination_class().page_size == 5 assert MockGenericViewSet.pagination_class().ordering == '-created_at' def test_adding_custom_pagination(self): class CustomPagination(pagination.BasePagination): pass @paginate(pagination_class=CustomPagination, kwarg1='kwarg1', kwarg2='kwarg2') class MockGenericViewSet(viewsets.GenericViewSet): pass assert hasattr(MockGenericViewSet, 'pagination_class') assert MockGenericViewSet.pagination_class().kwarg1 == 'kwarg1' assert MockGenericViewSet.pagination_class().kwarg2 == 'kwarg2' drf-extensions-0.6.0/tests_app/tests/unit/key_constructor/000077500000000000000000000000001361353156700240575ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/key_constructor/__init__.py000066400000000000000000000000011361353156700261570ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/unit/key_constructor/bits/000077500000000000000000000000001361353156700250205ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/key_constructor/bits/__init__.py000066400000000000000000000000011361353156700271200ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/unit/key_constructor/bits/models.py000066400000000000000000000001641361353156700266560ustar00rootroot00000000000000from django.db import models class BitTestModel(models.Model): is_active = models.BooleanField(default=False) drf-extensions-0.6.0/tests_app/tests/unit/key_constructor/bits/tests.py000066400000000000000000000522711361353156700265430ustar00rootroot00000000000000from mock import Mock from mock import PropertyMock from django.test import TestCase from django.utils.translation import override from rest_framework import views from rest_framework.response import Response from rest_framework_extensions.test import APIRequestFactory from rest_framework_extensions.key_constructor.bits import ( KeyBitDictBase, UniqueMethodIdKeyBit, LanguageKeyBit, FormatKeyBit, UserKeyBit, HeadersKeyBit, RequestMetaKeyBit, QueryParamsKeyBit, UniqueViewIdKeyBit, PaginationKeyBit, ListSqlQueryKeyBit, RetrieveSqlQueryKeyBit, ListModelKeyBit, RetrieveModelKeyBit, ArgsKeyBit, KwargsKeyBit, ) from .models import BitTestModel factory = APIRequestFactory() class KeyBitDictBaseTest(TestCase): def setUp(self): self.kwargs = { 'params': [], 'view_instance': None, 'view_method': None, 'request': None, 'args': None, 'kwargs': None } def test_should_raise_exception_if__get_source_dict__is_not_implemented(self): class KeyBitDictChild(KeyBitDictBase): pass try: KeyBitDictChild().get_data(**self.kwargs) except NotImplementedError: pass else: self.fail('Should raise NotImplementedError if "get_source_dict" method is not implemented') def test_should_return_empty_dict_if_source_dict_is_empty(self): class KeyBitDictChild(KeyBitDictBase): def get_source_dict(self, **kwargs): return {} self.assertEqual(KeyBitDictChild().get_data(**self.kwargs), {}) def test_should_retrieve_data_by_keys_from_params_list_from_source_dict(self): class KeyBitDictChild(KeyBitDictBase): def get_source_dict(self, **kwargs): return { 'id': 1, 'geobase_id': 123, 'name': 'London', } self.kwargs['params'] = ['name', 'geobase_id'] expected = { 'name': u'London', 'geobase_id': u'123', } self.assertEqual(KeyBitDictChild().get_data(**self.kwargs), expected) def test_should_not_retrieve_data_with_none_value(self): class KeyBitDictChild(KeyBitDictBase): def get_source_dict(self, **kwargs): return { 'id': 1, 'geobase_id': 123, 'name': None, } self.kwargs['params'] = ['name', 'geobase_id'] expected = { 'geobase_id': u'123', } self.assertEqual(KeyBitDictChild().get_data(**self.kwargs), expected) def test_should_force_text_for_value(self): class KeyBitDictChild(KeyBitDictBase): def get_source_dict(self, **kwargs): return { 'id': 1, 'geobase_id': 123, 'name': 'Лондон', } self.kwargs['params'] = ['name', 'geobase_id'] expected = { 'geobase_id': u'123', 'name': u'Лондон', } self.assertEqual(KeyBitDictChild().get_data(**self.kwargs), expected) def test_should_prepare_key_before_retrieving(self): class KeyBitDictChild(KeyBitDictBase): def get_source_dict(self, **kwargs): return { 'id': 1, 'GEOBASE_ID': 123, 'NAME': 'London', } def prepare_key_for_value_retrieving(self, key): return key.upper() self.kwargs['params'] = ['name', 'geobase_id'] expected = { 'geobase_id': u'123', 'name': u'London', } self.assertEqual(KeyBitDictChild().get_data(**self.kwargs), expected) def test_should_prepare_key_before_value_assignment(self): class KeyBitDictChild(KeyBitDictBase): def get_source_dict(self, **kwargs): return { 'id': 1, 'geobase_id': 123, 'name': 'London', } def prepare_key_for_value_assignment(self, key): return key.upper() self.kwargs['params'] = ['name', 'geobase_id'] expected = { 'GEOBASE_ID': u'123', 'NAME': u'London', } self.assertEqual(KeyBitDictChild().get_data(**self.kwargs), expected) def test_should_produce_exact_results_for_equal_params_attribute_with_different_items_ordering(self): class KeyBitDictChild(KeyBitDictBase): def get_source_dict(self, **kwargs): return { 'id': 1, 'GEOBASE_ID': 123, 'NAME': 'London', } self.kwargs['params'] = ['name', 'geobase_id'] response_1 = KeyBitDictChild().get_data(**self.kwargs) self.kwargs['params'] = ['geobase_id', 'name'] response_2 = KeyBitDictChild().get_data(**self.kwargs) self.assertEqual(response_1, response_2) class UniqueViewIdKeyBitTest(TestCase): def test_resulting_dict(self): class TestView(views.APIView): def get(self, request, *args, **kwargs): return Response('Response from method') view_instance = TestView() kwargs = { 'params': None, 'view_instance': view_instance, 'view_method': view_instance.get, 'request': None, 'args': None, 'kwargs': None } expected = u'tests_app.tests.unit.key_constructor.bits.tests' + u'.' + u'TestView' self.assertEqual(UniqueViewIdKeyBit().get_data(**kwargs), expected) class UniqueMethodIdKeyBitTest(TestCase): def test_resulting_dict(self): class TestView(views.APIView): def get(self, request, *args, **kwargs): return Response('Response from method') view_instance = TestView() kwargs = { 'params': None, 'view_instance': view_instance, 'view_method': view_instance.get, 'request': None, 'args': None, 'kwargs': None } expected = u'tests_app.tests.unit.key_constructor.bits.tests' + u'.' + u'TestView' + u'.' + u'get' self.assertEqual(UniqueMethodIdKeyBit().get_data(**kwargs), expected) class LanguageKeyBitTest(TestCase): def test_resulting_dict(self): kwargs = { 'params': None, 'view_instance': None, 'view_method': None, 'request': None, 'args': None, 'kwargs': None } expected = u'br' with override('br'): self.assertEqual(LanguageKeyBit().get_data(**kwargs), expected) class FormatKeyBitTest(TestCase): def test_resulting_dict(self): kwargs = { 'params': None, 'view_instance': None, 'view_method': None, 'request': factory.get(''), 'args': None, 'kwargs': None } kwargs['request'].accepted_renderer = Mock(format='super-format') expected = u'super-format' self.assertEqual(FormatKeyBit().get_data(**kwargs), expected) class UserKeyBitTest(TestCase): def setUp(self): self.kwargs = { 'params': None, 'view_instance': None, 'view_method': None, 'request': factory.get(''), 'args': None, 'kwargs': None } self.user = Mock() self.user.id = 123 self.is_authenticated = PropertyMock(return_value=False) type(self.user).is_authenticated = self.is_authenticated def test_without_user_in_request(self): expected = u'anonymous' self.assertEqual(UserKeyBit().get_data(**self.kwargs), expected) def test_with_not_autenticated_user(self): self.kwargs['request'].user = self.user expected = u'anonymous' self.assertEqual(UserKeyBit().get_data(**self.kwargs), expected) def test_with_autenticated_user(self): self.kwargs['request'].user = self.user self.is_authenticated.return_value = True expected = u'123' self.assertEqual(UserKeyBit().get_data(**self.kwargs), expected) class HeadersKeyBitTest(TestCase): def test_resulting_dict(self): self.kwargs = { 'params': ['Accept-Language', 'X-Geobase-Id', 'Not-Existing-Header'], 'view_instance': None, 'view_method': None, 'request': factory.get('', **{ 'HTTP_ACCEPT_LANGUAGE': 'Ru', 'HTTP_X_GEOBASE_ID': 123 }), 'args': None, 'kwargs': None } expected = { 'accept-language': u'Ru', 'x-geobase-id': u'123' } self.assertEqual(HeadersKeyBit().get_data(**self.kwargs), expected) class RequestMetaKeyBitTest(TestCase): def test_resulting_dict(self): self.kwargs = { 'params': ['REMOTE_ADDR', 'REMOTE_HOST', 'not_existing_key'], 'view_instance': None, 'view_method': None, 'request': factory.get('', **{ 'REMOTE_ADDR': '127.0.0.1', 'REMOTE_HOST': 'localhost' }), 'args': None, 'kwargs': None } expected = { 'REMOTE_ADDR': u'127.0.0.1', 'REMOTE_HOST': u'localhost' } self.assertEqual(RequestMetaKeyBit().get_data(**self.kwargs), expected) class QueryParamsKeyBitTest(TestCase): def setUp(self): self.kwargs = { 'params': None, 'view_instance': None, 'view_method': None, 'request': factory.get('?part=Londo&callback=jquery_callback'), 'args': None, 'kwargs': None } def test_resulting_dict(self): self.kwargs['params'] = ['part', 'callback', 'not_existing_param'] expected = { 'part': u'Londo', 'callback': u'jquery_callback' } self.assertEqual(QueryParamsKeyBit().get_data(**self.kwargs), expected) def test_resulting_dict_all_params(self): self.kwargs['params'] = '*' expected = { 'part': u'Londo', 'callback': u'jquery_callback' } self.assertEqual(QueryParamsKeyBit().get_data(**self.kwargs), expected) def test_default_params_is_all_args(self): self.assertEqual(QueryParamsKeyBit().params, '*') class PaginationKeyBitTest(TestCase): def setUp(self): self.kwargs = { 'params': None, 'view_instance': Mock(spec_set=['paginator']), 'view_method': None, 'request': factory.get('?page_size=10&page=1&limit=5&offset=15&cursor=foo'), 'args': None, 'kwargs': None } def test_view_without_pagination_arguments(self): self.kwargs['view_instance'] = Mock(spec_set=[]) self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {}) def test_view_with_empty_pagination_arguments(self): self.kwargs['view_instance'].paginator.page_query_param = None self.kwargs['view_instance'].paginator.page_size_query_param = None self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {}) def test_view_with_page_kwarg(self): self.kwargs['view_instance'].paginator.page_query_param = 'page' self.kwargs['view_instance'].paginator.page_size_query_param = None self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {'page': '1'}) def test_view_with_paginate_by_param(self): self.kwargs['view_instance'].paginator.page_query_param = None self.kwargs['view_instance'].paginator.page_size_query_param = 'page_size' self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {'page_size': '10'}) def test_view_with_all_pagination_attrs(self): self.kwargs['view_instance'].paginator.page_query_param = 'page' self.kwargs['view_instance'].paginator.page_size_query_param = 'page_size' self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {'page_size': '10', 'page': '1'}) def test_view_with_all_pagination_attrs__without_query_params(self): self.kwargs['view_instance'].paginator.page_query_param = 'page' self.kwargs['view_instance'].paginator.page_size_query_param = 'page_size' self.kwargs['request'] = factory.get('') self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {}) def test_view_with_offset_pagination_attrs(self): self.kwargs['view_instance'].paginator.limit_query_param = 'limit' self.kwargs['view_instance'].paginator.offset_query_param = 'offset' self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {'limit': '5', 'offset': '15'}) def test_view_with_cursor_pagination_attrs(self): self.kwargs['view_instance'].paginator.cursor_query_param = 'cursor' self.assertEqual(PaginationKeyBit().get_data(**self.kwargs), {'cursor': 'foo'}) class ListSqlQueryKeyBitTest(TestCase): def setUp(self): self.kwargs = { 'params': None, 'view_instance': Mock(), 'view_method': None, 'request': None, 'args': None, 'kwargs': None } self.kwargs['view_instance'].get_queryset = Mock(return_value=BitTestModel.objects.all()) self.kwargs['view_instance'].filter_queryset = lambda x: x.filter(is_active=True) def test_should_use_view__get_queryset__and_filter_it_with__filter_queryset(self): expected = (u'SELECT "unit_bittestmodel"."id", "unit_bittestmodel"."is_active" ' u'FROM "unit_bittestmodel" ' u'WHERE "unit_bittestmodel"."is_active" = True{space}') space = '' expected = expected.format(space=space) response = ListSqlQueryKeyBit().get_data(**self.kwargs) self.assertEqual(response, expected) def test_should_return_none_if_empty_queryset(self): self.kwargs['view_instance'].filter_queryset = lambda x: x.none() response = ListSqlQueryKeyBit().get_data(**self.kwargs) self.assertEqual(response, None) def test_should_return_none_if_empty_result_set_raised(self): self.kwargs['view_instance'].filter_queryset = lambda x: x.filter(pk__in=[]) response = ListSqlQueryKeyBit().get_data(**self.kwargs) self.assertEqual(response, None) class ListModelKeyBitTest(TestCase): def setUp(self): self.kwargs = { 'params': None, 'view_instance': Mock(), 'view_method': None, 'request': None, 'args': None, 'kwargs': None } self.kwargs['view_instance'].get_queryset = Mock(return_value=BitTestModel.objects.all()) self.kwargs['view_instance'].filter_queryset = lambda x: x.filter(is_active=True) def test_should_use_view__get_queryset__and_filter_it_with__filter_queryset(self): # create 4 models BitTestModel.objects.create(is_active=True) BitTestModel.objects.create(is_active=True) BitTestModel.objects.create(is_active=True) BitTestModel.objects.create(is_active=True) expected = u"[(1, True), (2, True), (3, True), (4, True)]" response = ListModelKeyBit().get_data(**self.kwargs) self.assertEqual(response, expected) def test_should_return_none_if_empty_queryset(self): self.kwargs['view_instance'].filter_queryset = lambda x: x.none() response = ListModelKeyBit().get_data(**self.kwargs) self.assertEqual(response, None) def test_should_return_none_if_empty_result_set_raised(self): self.kwargs['view_instance'].filter_queryset = lambda x: x.filter(pk__in=[]) response = ListModelKeyBit().get_data(**self.kwargs) self.assertEqual(response, None) class RetrieveSqlQueryKeyBitTest(TestCase): def setUp(self): self.kwargs = { 'params': None, 'view_instance': Mock(), 'view_method': None, 'request': None, 'args': None, 'kwargs': None } self.kwargs['view_instance'].kwargs = {'id': 123} self.kwargs['view_instance'].lookup_field = 'id' self.kwargs['view_instance'].get_queryset = Mock(return_value=BitTestModel.objects.all()) self.kwargs['view_instance'].filter_queryset = lambda x: x.filter(is_active=True) def test_should_use_view__get_queryset__and_filter_it_with__filter_queryset__and_filter_by__lookup_field(self): expected = (u'SELECT "unit_bittestmodel"."id", "unit_bittestmodel"."is_active" ' u'FROM "unit_bittestmodel" ' u'WHERE ("unit_bittestmodel"."is_active" = True {space}AND "unit_bittestmodel"."id" = 123{space})') space = '' expected = expected.format(space=space) response = RetrieveSqlQueryKeyBit().get_data(**self.kwargs) self.assertEqual(response, expected) def test_with_bad_lookup_value(self): self.kwargs['view_instance'].kwargs = {'id': "I'm ganna hack u are!"} response = RetrieveSqlQueryKeyBit().get_data(**self.kwargs) self.assertEqual(response, None) def test_should_return_none_if_empty_queryset(self): self.kwargs['view_instance'].filter_queryset = lambda x: x.none() response = RetrieveSqlQueryKeyBit().get_data(**self.kwargs) self.assertEqual(response, None) def test_should_return_none_if_empty_result_set_raised(self): self.kwargs['view_instance'].filter_queryset = lambda x: x.filter(pk__in=[]) response = RetrieveSqlQueryKeyBit().get_data(**self.kwargs) self.assertEqual(response, None) class RetrieveModelKeyBitTest(TestCase): def setUp(self): self.kwargs = { 'params': None, 'view_instance': Mock(), 'view_method': None, 'request': None, 'args': None, 'kwargs': None } self.kwargs['view_instance'].kwargs = {'id': 123} self.kwargs['view_instance'].lookup_field = 'id' self.kwargs['view_instance'].get_queryset = Mock(return_value=BitTestModel.objects.all()) self.kwargs['view_instance'].filter_queryset = lambda x: x.filter(is_active=True) def test_should_use_view__get_queryset__and_filter_it_with__filter_queryset__and_filter_by__lookup_field(self): model = BitTestModel.objects.create(is_active=True) self.kwargs['view_instance'].kwargs = {'id': model.id} expected = u"[(%s, True)]" % model.id response = RetrieveModelKeyBit().get_data(**self.kwargs) self.assertEqual(response, expected) def test_with_bad_lookup_value(self): self.kwargs['view_instance'].kwargs = {'id': "I'm ganna hack u are!"} response = RetrieveModelKeyBit().get_data(**self.kwargs) self.assertEqual(response, None) def test_should_return_none_if_empty_queryset(self): self.kwargs['view_instance'].filter_queryset = lambda x: x.none() response = RetrieveModelKeyBit().get_data(**self.kwargs) self.assertEqual(response, None) def test_should_return_none_if_empty_result_set_raised(self): self.kwargs['view_instance'].filter_queryset = lambda x: x.filter(pk__in=[]) response = RetrieveModelKeyBit().get_data(**self.kwargs) self.assertEqual(response, None) class ArgsKeyBitTest(TestCase): def setUp(self): self.test_args = ['abc', 'foobar', 'xyz'] self.kwargs = { 'params': None, 'view_instance': None, 'view_method': None, 'request': None, 'args': self.test_args, 'kwargs': None } def test_with_no_args(self): self.assertEqual(ArgsKeyBit().get_data(**self.kwargs), []) def test_with_all_args(self): self.kwargs['params'] = '*' self.assertEqual(ArgsKeyBit().get_data(**self.kwargs), self.test_args) def test_with_specified_args(self): self.kwargs['params'] = test_arg_idx = [0, 2] expected_args = [self.test_args[i] for i in test_arg_idx] self.assertEqual(ArgsKeyBit().get_data(**self.kwargs), expected_args) def test_default_params_is_all_args(self): self.assertEqual(ArgsKeyBit().params, '*') class KwargsKeyBitTest(TestCase): def setUp(self): self.test_kwargs = { 'one': '1', 'city': 'London', } self.kwargs = { 'params': None, 'view_instance': None, 'view_method': None, 'request': None, 'args': None, 'kwargs': self.test_kwargs, } def test_resulting_dict_all_kwargs(self): self.kwargs['params'] = '*' self.assertEqual(KwargsKeyBit().get_data(**self.kwargs), self.test_kwargs) def test_resulting_dict_specified_kwargs(self): keys = ['one', 'not_existing_param'] expected_kwargs = {'one': self.test_kwargs['one']} self.kwargs['params'] = keys self.assertEqual(KwargsKeyBit().get_data(**self.kwargs), expected_kwargs) def test_resulting_dict_no_kwargs(self): self.assertEqual(KwargsKeyBit().get_data(**self.kwargs), {}) def test_default_params_is_all_args(self): self.assertEqual(KwargsKeyBit().params, '*') drf-extensions-0.6.0/tests_app/tests/unit/key_constructor/constructor/000077500000000000000000000000001361353156700264445ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/key_constructor/constructor/__init__.py000066400000000000000000000000011361353156700305440ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/unit/key_constructor/constructor/tests.py000066400000000000000000000254631361353156700301720ustar00rootroot00000000000000from copy import deepcopy import hashlib import json from mock import Mock, patch from django.test import TestCase from rest_framework import viewsets from rest_framework_extensions.key_constructor.constructors import ( KeyConstructor, ) from rest_framework_extensions.utils import get_unique_method_id from rest_framework_extensions.test import APIRequestFactory from tests_app.testutils import ( override_extensions_api_settings, TestFormatKeyBit, TestUsedKwargsKeyBit, TestLanguageKeyBit, ) factory = APIRequestFactory() class KeyConstructorTest_bits(TestCase): def test_me(self): class MyKeyConstructor(KeyConstructor): format = TestFormatKeyBit() language = TestLanguageKeyBit() constructor_instance = MyKeyConstructor() expected = { 'format': MyKeyConstructor.format, 'language': MyKeyConstructor.language } self.assertEqual(constructor_instance.bits, expected) def test_with_inheritance(self): class MyKeyConstructor(KeyConstructor): format = TestFormatKeyBit() class ChildKeyConstructor(MyKeyConstructor): language = TestLanguageKeyBit() constructor_instance = ChildKeyConstructor() expected = { 'format': ChildKeyConstructor.format, 'language': ChildKeyConstructor.language } self.assertEqual(constructor_instance.bits, expected) class KeyConstructorTest(TestCase): def setUp(self): class View(viewsets.ReadOnlyModelViewSet): pass view_intance = View() view_method = view_intance.retrieve self.kwargs = { 'view_instance': view_intance, 'view_method': view_method, 'request': factory.get(''), 'args': None, 'kwargs': None } def test_prepare_key_consistency_for_equal_dicts_with_different_key_positions(self): class MyKeyConstructor(KeyConstructor): pass constructor_instance = MyKeyConstructor() one = {'used_kwargs': {'view_method': 'view_method', 'kwargs': None, 'view_instance': 'view_instance', 'params': {'hello': 'world'}, 'args': None, 'request': 'request'}} two = {'used_kwargs': {'params': {'hello': 'world'}, 'kwargs': None, 'view_method': 'view_method', 'view_instance': 'view_instance', 'args': None, 'request': 'request'}} self.assertEqual(one, two) self.assertEqual(constructor_instance.prepare_key(one), constructor_instance.prepare_key(two)) def prepare_key(self, key_dict): return hashlib.md5(json.dumps(key_dict, sort_keys=True).encode('utf-8')).hexdigest() def test_key_construction__with_bits_without_params(self): class MyKeyConstructor(KeyConstructor): format = TestFormatKeyBit() language = TestLanguageKeyBit() constructor_instance = MyKeyConstructor() response = constructor_instance(**self.kwargs) expected = { 'format': u'json', 'language': u'ru', } self.assertEqual(response, self.prepare_key(expected)) def test_key_construction__with_bits_with_params(self): class MyKeyConstructor(KeyConstructor): used_kwargs = TestUsedKwargsKeyBit(params={'hello': 'world'}) with patch.object(json.JSONEncoder, 'default', Mock(return_value='force serializing')): constructor_instance = MyKeyConstructor() response = constructor_instance(**self.kwargs) expected_value = deepcopy(self.kwargs) expected_value['params'] = {'hello': 'world'} expected_data_from_bits = { 'used_kwargs': expected_value } msg = 'Data from bits: {data_from_bits}\nExpected data from: {expected_data_from_bits}'.format( data_from_bits=json.dumps(constructor_instance.get_data_from_bits(**self.kwargs)), expected_data_from_bits=json.dumps(expected_data_from_bits) ) self.assertEqual(response, self.prepare_key(expected_data_from_bits), msg=msg) def test_two_key_construction_with_same_bits_in_different_order_should_produce_equal_keys(self): class MyKeyConstructor_1(KeyConstructor): language = TestLanguageKeyBit() format = TestFormatKeyBit() class MyKeyConstructor_2(KeyConstructor): format = TestFormatKeyBit() language = TestLanguageKeyBit() self.assertEqual( MyKeyConstructor_1()(**self.kwargs), MyKeyConstructor_2()(**self.kwargs) ) def test_key_construction__with_bits_with_params__and_with_constructor_with_params(self): class MyKeyConstructor(KeyConstructor): used_kwargs = TestUsedKwargsKeyBit(params={'hello': 'world'}) with patch.object(json.JSONEncoder, 'default', Mock(return_value='force serializing')): constructor_instance = MyKeyConstructor(params={ 'used_kwargs': {'goodbye': 'moon'} }) response = constructor_instance(**self.kwargs) expected_value = deepcopy(self.kwargs) expected_value['params'] = {'goodbye': 'moon'} expected_data_from_bits = { 'used_kwargs': expected_value } msg = 'Data from bits: {data_from_bits}\nExpected data from: {expected_data_from_bits}'.format( data_from_bits=json.dumps(constructor_instance.get_data_from_bits(**self.kwargs)), expected_data_from_bits=json.dumps(expected_data_from_bits) ) self.assertEqual(response, self.prepare_key(expected_data_from_bits), msg=msg) class KeyConstructorTest___get_memoization_key(TestCase): def setUp(self): class View(viewsets.ReadOnlyModelViewSet): pass self.view_intance = View() self.view_method = self.view_intance.retrieve self.request = factory.get('') def test_me(self): class MyKeyConstructor(KeyConstructor): language = TestLanguageKeyBit() format = TestFormatKeyBit() constructor_instance = MyKeyConstructor() response = constructor_instance._get_memoization_key( view_instance=self.view_intance, view_method=self.view_method, args=[1, 2, 3, u'Привет мир'], kwargs={1: 2, 3: 4, u'привет': u'мир'} ) expected = json.dumps({ 'unique_method_id': get_unique_method_id(view_instance=self.view_intance, view_method=self.view_method), 'args': [1, 2, 3, u'Привет мир'], 'kwargs': {1: 2, 3: 4, u'привет': u'мир'}, 'instance_id': id(constructor_instance) }) self.assertEqual(response, expected) class KeyConstructorTestBehavior__memoization(TestCase): def setUp(self): class View(viewsets.ReadOnlyModelViewSet): pass class MyKeyConstructor(KeyConstructor): format = TestFormatKeyBit() language = TestLanguageKeyBit() view_intance = View() view_method = view_intance.retrieve self.MyKeyConstructor = MyKeyConstructor self.kwargs = { 'view_instance': view_intance, 'view_method': view_method, 'request': factory.get(''), 'args': None, 'kwargs': None } def test_should_not_memoize_by_default(self): constructor_instance = self.MyKeyConstructor() response_1 = constructor_instance(**self.kwargs) response_2 = constructor_instance(**self.kwargs) self.assertFalse(response_1 is response_2) def test_should_memoize_if_asked(self): constructor_instance = self.MyKeyConstructor(memoize_for_request=True) response_1 = constructor_instance(**self.kwargs) response_2 = constructor_instance(**self.kwargs) self.assertTrue(response_1 is response_2) def test_should_use_value_from_settings_for_default_memoize_boolean_value(self): with override_extensions_api_settings(DEFAULT_KEY_CONSTRUCTOR_MEMOIZE_FOR_REQUEST=True): constructor_instance = KeyConstructor() self.assertTrue(constructor_instance.memoize_for_request) with override_extensions_api_settings(DEFAULT_KEY_CONSTRUCTOR_MEMOIZE_FOR_REQUEST=False): constructor_instance = KeyConstructor() self.assertFalse(constructor_instance.memoize_for_request) def test_should_memoize_in_request_instance(self): constructor_instance = self.MyKeyConstructor(memoize_for_request=True) response_1 = constructor_instance(**self.kwargs) self.kwargs['request'] = factory.get('') response_2 = constructor_instance(**self.kwargs) self.assertFalse(response_1 is response_2) def test_should_use_different_memoization_for_different_arguments(self): constructor_instance = self.MyKeyConstructor(memoize_for_request=True) response_1 = constructor_instance(**self.kwargs) response_2 = constructor_instance(**self.kwargs) self.assertTrue(response_1 is response_2) self.kwargs['args'] = [1, 2, 3, u'привет мир'] response_3 = constructor_instance(**self.kwargs) response_4 = constructor_instance(**self.kwargs) self.assertTrue(response_3 is response_4) self.assertFalse(response_1 is response_3) self.assertFalse(response_1 is response_4) self.assertFalse(response_2 is response_3) self.assertFalse(response_2 is response_4) def test_should_use_different_memoization_for_different_request_instances(self): constructor_instance = self.MyKeyConstructor(memoize_for_request=True) response_1 = constructor_instance(**self.kwargs) self.kwargs['request'] = factory.get('') response_2 = constructor_instance(**self.kwargs) self.assertFalse(response_1 is response_2) def test_should_use_different_memoization_for_different_constructor_instances(self): constructor_instance_1 = self.MyKeyConstructor(memoize_for_request=True) constructor_instance_2 = self.MyKeyConstructor(memoize_for_request=True) response_1 = constructor_instance_1(**self.kwargs) response_2 = constructor_instance_2(**self.kwargs) self.assertFalse(response_1 is response_2) def test_should_use_different_memoization_for_different_views_with_same_method(self): class View_2(viewsets.ReadOnlyModelViewSet): pass constructor_instance = self.MyKeyConstructor(memoize_for_request=True) response_1 = constructor_instance(**self.kwargs) view_2_instance = View_2() self.kwargs['view_instance'] = view_2_instance self.kwargs['view_instance'] = view_2_instance.retrieve response_2 = constructor_instance(**self.kwargs) self.assertFalse(response_1 is response_2) drf-extensions-0.6.0/tests_app/tests/unit/migrations/000077500000000000000000000000001361353156700227765ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/migrations/0001_initial.py000066400000000000000000000054211361353156700254430ustar00rootroot00000000000000# Generated by Django 2.2 on 2019-04-16 11:51 from django.db import migrations, models import django.db.models.deletion class Migration(migrations.Migration): initial = True dependencies = [ ] operations = [ migrations.CreateModel( name='BitTestModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('is_active', models.BooleanField(default=False)), ], ), migrations.CreateModel( name='NestedRouterMixinGroupModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=10)), ], ), migrations.CreateModel( name='NestedRouterMixinPermissionModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=10)), ], ), migrations.CreateModel( name='UserModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=20)), ], ), migrations.CreateModel( name='NestedRouterMixinUserModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=10)), ('groups', models.ManyToManyField(related_name='user_groups', to='unit.NestedRouterMixinGroupModel')), ], ), migrations.AddField( model_name='nestedroutermixingroupmodel', name='permissions', field=models.ManyToManyField(to='unit.NestedRouterMixinPermissionModel'), ), migrations.CreateModel( name='CommentModel', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(max_length=20)), ('text', models.CharField(max_length=200)), ('attachment', models.FileField(blank=True, max_length=500, null=True, upload_to='test_serializers')), ('hidden_text', models.CharField(blank=True, max_length=200, null=True)), ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='unit.UserModel')), ('users_liked', models.ManyToManyField(blank=True, to='unit.UserModel')), ], ), ] drf-extensions-0.6.0/tests_app/tests/unit/migrations/__init__.py000066400000000000000000000000001361353156700250750ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/models.py000066400000000000000000000001771361353156700224640ustar00rootroot00000000000000from .key_constructor.bits.models import * from .routers.nested_router_mixin.models import * from .serializers.models import * drf-extensions-0.6.0/tests_app/tests/unit/routers/000077500000000000000000000000001361353156700223255ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/routers/__init__.py000066400000000000000000000000001361353156700244240ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/routers/nested_router_mixin/000077500000000000000000000000001361353156700264135ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/routers/nested_router_mixin/__init__.py000066400000000000000000000000011361353156700305130ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/unit/routers/nested_router_mixin/models.py000066400000000000000000000007711361353156700302550ustar00rootroot00000000000000from django.db import models class NestedRouterMixinPermissionModel(models.Model): name = models.CharField(max_length=10) class NestedRouterMixinGroupModel(models.Model): name = models.CharField(max_length=10) permissions = models.ManyToManyField( 'NestedRouterMixinPermissionModel') class NestedRouterMixinUserModel(models.Model): name = models.CharField(max_length=10) groups = models.ManyToManyField( 'NestedRouterMixinGroupModel', related_name='user_groups') drf-extensions-0.6.0/tests_app/tests/unit/routers/nested_router_mixin/tests.py000066400000000000000000000201661361353156700301340ustar00rootroot00000000000000from rest_framework.compat import get_regex_pattern from rest_framework_extensions.test import APITestCase from rest_framework_extensions.routers import ExtendedSimpleRouter from rest_framework_extensions.utils import compose_parent_pk_kwarg_name from .views import ( UserViewSet, GroupViewSet, PermissionViewSet, CustomRegexUserViewSet, CustomRegexGroupViewSet, CustomRegexPermissionViewSet, ) class NestedRouterMixinTest(APITestCase): def get_lookup_regex(self, value): return '(?P<{0}>[^/.]+)'.format(value) def get_parent_lookup_regex(self, value): return '(?P<{0}>[^/.]+)'.format(compose_parent_pk_kwarg_name(value)) def get_custom_regex_lookup(self, pk_kwarg_name, lookup_value_regex): """ Build lookup regex with custom regular expression. """ return '(?P<{pk_kwarg_name}>{lookup_value_regex})'.format( pk_kwarg_name=pk_kwarg_name, lookup_value_regex=lookup_value_regex ) def get_custom_regex_parent_lookup(self, parent_pk_kwarg_name, parent_lookup_value_regex): """ Build parent lookup regex with custom regular expression. """ return self.get_custom_regex_lookup( compose_parent_pk_kwarg_name(parent_pk_kwarg_name), parent_lookup_value_regex ) def test_one_route(self): router = ExtendedSimpleRouter() router.register(r'users', UserViewSet, 'user') # test user list self.assertEqual(router.urls[0].name, 'user-list') self.assertEqual(get_regex_pattern(router.urls[0]), r'^users/$') # test user detail self.assertEqual(router.urls[1].name, 'user-detail') self.assertEqual(get_regex_pattern(router.urls[1]), r'^users/{0}/$'.format(self.get_lookup_regex('pk'))) def test_nested_route(self): router = ExtendedSimpleRouter() ( router.register(r'users', UserViewSet, 'user') .register(r'groups', GroupViewSet, 'users-group', parents_query_lookups=['user']) ) # test user list self.assertEqual(router.urls[0].name, 'user-list') self.assertEqual(get_regex_pattern(router.urls[0]), r'^users/$') # test user detail self.assertEqual(router.urls[1].name, 'user-detail') self.assertEqual(get_regex_pattern(router.urls[1]), r'^users/{0}/$'.format(self.get_lookup_regex('pk'))) # test users group list self.assertEqual(router.urls[2].name, 'users-group-list') self.assertEqual(get_regex_pattern(router.urls[2]), r'^users/{0}/groups/$'.format( self.get_parent_lookup_regex('user') ) ) # test users group detail self.assertEqual(router.urls[3].name, 'users-group-detail') self.assertEqual(get_regex_pattern(router.urls[3]), r'^users/{0}/groups/{1}/$'.format( self.get_parent_lookup_regex('user'), self.get_lookup_regex('pk') ), ) def test_nested_route_depth_3(self): router = ExtendedSimpleRouter() ( router.register(r'users', UserViewSet, 'user') .register(r'groups', GroupViewSet, 'users-group', parents_query_lookups=['user']) .register(r'permissions', PermissionViewSet, 'users-groups-permission', parents_query_lookups=[ 'group__user', 'group', ] ) ) # test user list self.assertEqual(router.urls[0].name, 'user-list') self.assertEqual(get_regex_pattern(router.urls[0]), r'^users/$') # test user detail self.assertEqual(router.urls[1].name, 'user-detail') self.assertEqual(get_regex_pattern(router.urls[1]), r'^users/{0}/$'.format(self.get_lookup_regex('pk'))) # test users group list self.assertEqual(router.urls[2].name, 'users-group-list') self.assertEqual(get_regex_pattern(router.urls[2]), r'^users/{0}/groups/$'.format( self.get_parent_lookup_regex('user') ) ) # test users group detail self.assertEqual(router.urls[3].name, 'users-group-detail') self.assertEqual(get_regex_pattern(router.urls[3]), r'^users/{0}/groups/{1}/$'.format( self.get_parent_lookup_regex('user'), self.get_lookup_regex('pk') ), ) # test users groups permission list self.assertEqual(router.urls[4].name, 'users-groups-permission-list') self.assertEqual(get_regex_pattern(router.urls[4]), r'^users/{0}/groups/{1}/permissions/$'.format( self.get_parent_lookup_regex('group__user'), self.get_parent_lookup_regex('group'), ) ) # test users groups permission detail self.assertEqual(router.urls[5].name, 'users-groups-permission-detail') self.assertEqual(get_regex_pattern(router.urls[5]), r'^users/{0}/groups/{1}/permissions/{2}/$'.format( self.get_parent_lookup_regex('group__user'), self.get_parent_lookup_regex('group'), self.get_lookup_regex('pk') ), ) def test_nested_route_depth_3_custom_regex(self): """ Nested routes with over two level of depth should respect all parents' `lookup_value_regex` attribute. """ router = ExtendedSimpleRouter() ( router.register(r'users', CustomRegexUserViewSet, 'user') .register(r'groups', CustomRegexGroupViewSet, 'users-group', parents_query_lookups=['user']) .register(r'permissions', CustomRegexPermissionViewSet, 'users-groups-permission', parents_query_lookups=[ 'group__user', 'group', ] ) ) # custom regex configuration user_viewset_regex = CustomRegexUserViewSet.lookup_value_regex group_viewset_regex = CustomRegexGroupViewSet.lookup_value_regex perm_viewset_regex = CustomRegexPermissionViewSet.lookup_value_regex # test user list self.assertEqual(router.urls[0].name, 'user-list') self.assertEqual(get_regex_pattern(router.urls[0]), r'^users/$') # test user detail self.assertEqual(router.urls[1].name, 'user-detail') self.assertEqual(get_regex_pattern(router.urls[1]), r'^users/{0}/$'.format( self.get_custom_regex_lookup('pk', user_viewset_regex)) ) # test users group list self.assertEqual(router.urls[2].name, 'users-group-list') self.assertEqual(get_regex_pattern(router.urls[2]), r'^users/{0}/groups/$'.format( self.get_custom_regex_parent_lookup('user', user_viewset_regex) ) ) # test users group detail self.assertEqual(router.urls[3].name, 'users-group-detail') self.assertEqual(get_regex_pattern(router.urls[3]), r'^users/{0}/groups/{1}/$'.format( self.get_custom_regex_parent_lookup('user', user_viewset_regex), self.get_custom_regex_lookup('pk', group_viewset_regex) ), ) # test users groups permission list self.assertEqual(router.urls[4].name, 'users-groups-permission-list') self.assertEqual(get_regex_pattern(router.urls[4]), r'^users/{0}/groups/{1}/permissions/$'.format( self.get_custom_regex_parent_lookup('group__user', user_viewset_regex), self.get_custom_regex_parent_lookup('group', group_viewset_regex), ) ) # test users groups permission detail self.assertEqual(router.urls[5].name, 'users-groups-permission-detail') self.assertEqual(get_regex_pattern(router.urls[5]), r'^users/{0}/groups/{1}/permissions/{2}/$'.format( self.get_custom_regex_parent_lookup('group__user', user_viewset_regex), self.get_custom_regex_parent_lookup('group', group_viewset_regex), self.get_custom_regex_lookup('pk', perm_viewset_regex) ), ) drf-extensions-0.6.0/tests_app/tests/unit/routers/nested_router_mixin/views.py000066400000000000000000000013111361353156700301160ustar00rootroot00000000000000from rest_framework.viewsets import ModelViewSet from .models import ( NestedRouterMixinUserModel as UserModel, NestedRouterMixinGroupModel as GroupModel, NestedRouterMixinPermissionModel as PermissionModel, ) class UserViewSet(ModelViewSet): model = UserModel class GroupViewSet(ModelViewSet): model = GroupModel class PermissionViewSet(ModelViewSet): model = PermissionModel class CustomRegexUserViewSet(ModelViewSet): lookup_value_regex = 'a' model = UserModel class CustomRegexGroupViewSet(ModelViewSet): lookup_value_regex = 'b' model = GroupModel class CustomRegexPermissionViewSet(ModelViewSet): lookup_value_regex = 'c' model = PermissionModel drf-extensions-0.6.0/tests_app/tests/unit/routers/tests.py000066400000000000000000000204721361353156700240460ustar00rootroot00000000000000from django.test import TestCase from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.response import Response from rest_framework_extensions.routers import ExtendedDefaultRouter class ExtendedDefaultRouterTest(TestCase): def setUp(self): self.router = ExtendedDefaultRouter() def get_routes_names(self, routes): return [i.name for i in routes] def get_dynamic_route_by_def_name(self, def_name, routes): try: return [i for i in routes if def_name in i.mapping.values()][0] except IndexError: return None def test_dynamic_list_route_should_come_before_detail_route(self): class BasicViewSet(viewsets.ViewSet): def list(self, request, *args, **kwargs): return Response({'method': 'list'}) @action(detail=False) def detail1(self, request, *args, **kwargs): return Response({'method': 'detail1'}) routes = self.router.get_routes(BasicViewSet) expected = [ '{basename}-list', '{basename}-detail1', '{basename}-detail' ] msg = '@detail_route methods should come first in routes order' self.assertEqual(self.get_routes_names(routes), expected, msg) def test_detail_route(self): class BasicViewSet(viewsets.ViewSet): @action(detail=True) def action1(self, request, *args, **kwargs): pass routes = self.router.get_routes(BasicViewSet) action1_route = self.get_dynamic_route_by_def_name('action1', routes) msg = '@detail_route should map methods to def name' self.assertEqual(action1_route.mapping, {'get': 'action1'}, msg) msg = '@detail_route should use url with detail lookup' self.assertEqual(action1_route.url, u'^{prefix}/{lookup}/action1{trailing_slash}$', msg) def test_detail_route__with_methods(self): class BasicViewSet(viewsets.ViewSet): @action(detail=True, methods=['post']) def action1(self, request, *args, **kwargs): pass routes = self.router.get_routes(BasicViewSet) action1_route = self.get_dynamic_route_by_def_name('action1', routes) msg = '@detail_route should map methods to def name' self.assertEqual(action1_route.mapping, {'post': 'action1'}, msg) msg = '@detail_route should use url with detail lookup' self.assertEqual(action1_route.url, u'^{prefix}/{lookup}/action1{trailing_slash}$', msg) def test_detail_route__with_methods__and__with_url_path(self): class BasicViewSet(viewsets.ViewSet): @action(detail=True, methods=['post'], url_path='action-one') def action1(self, request, *args, **kwargs): pass routes = self.router.get_routes(BasicViewSet) action1_route = self.get_dynamic_route_by_def_name('action1', routes) msg = '@detail_route should map methods to "url_path"' self.assertEqual(action1_route.mapping, {'post': 'action1'}, msg) msg = '@detail_route should use url with detail lookup and "url_path" value' self.assertEqual(action1_route.url, u'^{prefix}/{lookup}/action-one{trailing_slash}$', msg) def test_list_route(self): class BasicViewSet(viewsets.ViewSet): @action(detail=False) def action1(self, request, *args, **kwargs): pass routes = self.router.get_routes(BasicViewSet) action1_route = self.get_dynamic_route_by_def_name('action1', routes) msg = '@list_route should map methods to def name' self.assertEqual(action1_route.mapping, {'get': 'action1'}, msg) msg = '@list_route should use url in list scope' self.assertEqual(action1_route.url, u'^{prefix}/action1{trailing_slash}$', msg) def test_list_route__with_methods(self): class BasicViewSet(viewsets.ViewSet): @action(detail=False, methods=['post']) def action1(self, request, *args, **kwargs): pass routes = self.router.get_routes(BasicViewSet) action1_route = self.get_dynamic_route_by_def_name('action1', routes) msg = '@list_route should map methods to def name' self.assertEqual(action1_route.mapping, {'post': 'action1'}, msg) msg = '@list_route should use url in list scope' self.assertEqual(action1_route.url, u'^{prefix}/action1{trailing_slash}$', msg) def test_list_route__with_methods__and__with_url_path(self): class BasicViewSet(viewsets.ViewSet): @action(detail=False, methods=['post'], url_path='action-one') def action1(self, request, *args, **kwargs): pass routes = self.router.get_routes(BasicViewSet) action1_route = self.get_dynamic_route_by_def_name('action1', routes) msg = '@list_route should map methods to "url_path"' self.assertEqual(action1_route.mapping, {'post': 'action1'}, msg) msg = '@list_route should use url in list scope with "url_path" value' self.assertEqual(action1_route.url, u'^{prefix}/action-one{trailing_slash}$', msg) def test_list_route_and_detail_route_with_exact_names(self): class BasicViewSet(viewsets.ViewSet): @action(detail=False, url_path='action-one') def action1(self, request, *args, **kwargs): pass @action(detail=True, url_path='action-one') def action1_detail(self, request, *args, **kwargs): pass routes = self.router.get_routes(BasicViewSet) action1_list_route = self.get_dynamic_route_by_def_name('action1', routes) action1_detail_route = self.get_dynamic_route_by_def_name('action1_detail', routes) self.assertEqual(action1_list_route.mapping, {'get': 'action1'}) self.assertEqual(action1_list_route.url, u'^{prefix}/action-one{trailing_slash}$') self.assertEqual(action1_detail_route.mapping, {'get': 'action1_detail'}) self.assertEqual(action1_detail_route.url, u'^{prefix}/{lookup}/action-one{trailing_slash}$') def test_list_route_and_detail_route_names(self): class BasicViewSet(viewsets.ViewSet): @action(detail=False) def action1(self, request, *args, **kwargs): pass @action(detail=True) def action2(self, request, *args, **kwargs): pass routes = self.router.get_routes(BasicViewSet) action1_list_route = self.get_dynamic_route_by_def_name('action1', routes) action2_detail_route = self.get_dynamic_route_by_def_name('action2', routes) self.assertEqual(action1_list_route.name, u'{basename}-action1') self.assertEqual(action2_detail_route.name, u'{basename}-action2') def test_list_route_and_detail_route_default_names__with_endpoints(self): class BasicViewSet(viewsets.ViewSet): @action(detail=False, url_path='action_one') def action1(self, request, *args, **kwargs): pass @action(detail=True, url_path='action-two') def action2(self, request, *args, **kwargs): pass routes = self.router.get_routes(BasicViewSet) action1_list_route = self.get_dynamic_route_by_def_name('action1', routes) action2_detail_route = self.get_dynamic_route_by_def_name('action2', routes) self.assertEqual(action1_list_route.name, u'{basename}-action1') self.assertEqual(action2_detail_route.name, u'{basename}-action2') def test_list_route_and_detail_route_names__with_endpoints(self): class BasicViewSet(viewsets.ViewSet): @action(detail=False, url_path='action_one', url_name='action_one') def action1(self, request, *args, **kwargs): pass @action(detail=True, url_path='action-two', url_name='action-two') def action2(self, request, *args, **kwargs): pass routes = self.router.get_routes(BasicViewSet) action1_list_route = self.get_dynamic_route_by_def_name('action1', routes) action2_detail_route = self.get_dynamic_route_by_def_name('action2', routes) self.assertEqual(action1_list_route.name, u'{basename}-action_one') self.assertEqual(action2_detail_route.name, u'{basename}-action-two') drf-extensions-0.6.0/tests_app/tests/unit/serializers/000077500000000000000000000000001361353156700231565ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/serializers/__init__.py000066400000000000000000000000011361353156700252560ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/unit/serializers/models.py000066400000000000000000000011311361353156700250070ustar00rootroot00000000000000from django.db import models class UserModel(models.Model): name = models.CharField(max_length=20) class CommentModel(models.Model): user = models.ForeignKey( UserModel, related_name='comments', on_delete=models.CASCADE, ) users_liked = models.ManyToManyField(UserModel, blank=True) title = models.CharField(max_length=20) text = models.CharField(max_length=200) attachment = models.FileField( upload_to='test_serializers', blank=True, null=True, max_length=500) hidden_text = models.CharField(max_length=200, blank=True, null=True) drf-extensions-0.6.0/tests_app/tests/unit/serializers/serializers.py000066400000000000000000000025061361353156700260670ustar00rootroot00000000000000from rest_framework import serializers from rest_framework_extensions import serializers as drf_serializers from .models import CommentModel, UserModel class UserSerializer(drf_serializers.PartialUpdateSerializerMixin, serializers.ModelSerializer): class Meta: model = UserModel fields = ( 'name', 'comments' ) class CommentSerializer(drf_serializers.PartialUpdateSerializerMixin, serializers.ModelSerializer): title_from_source = serializers.CharField(source='title', required=False) class Meta: model = CommentModel fields = ( 'id', 'user', 'users_liked', 'title', 'text', 'attachment', 'title_from_source' ) class CommentSerializerWithAllowedUserId(CommentSerializer): user_id = serializers.IntegerField() class Meta(CommentSerializer.Meta): fields = ('user_id',) + CommentSerializer.Meta.fields class CommentSerializerWithExpandedUsersLiked(drf_serializers.PartialUpdateSerializerMixin, serializers.ModelSerializer): user = UserSerializer() class Meta: model = CommentModel fields = ( 'title', 'user' ) drf-extensions-0.6.0/tests_app/tests/unit/serializers/tests.py000066400000000000000000000225441361353156700247010ustar00rootroot00000000000000from django.test import TestCase from django.core.files.base import ContentFile from .serializers import CommentSerializer, UserSerializer, \ CommentSerializerWithAllowedUserId from .models import UserModel, CommentModel class PartialUpdateSerializerMixinTest(TestCase): def setUp(self): self.files = [ ContentFile(u'file one'.encode('utf-8'), name='file1.txt'), ContentFile(u'file two'.encode('utf-8'), name='file2.txt'), ] self.files[0].size = 8 self.files[1].size = 8 self.user = UserModel.objects.create(name='gena') self.comment = CommentModel.objects.create( user=self.user, title='hello', text='world', attachment=self.files[0] ) def get_comment(self): return CommentModel.objects.get(pk=self.comment.pk) def test_should_use_default_saving_without_partial(self): serializer = CommentSerializer(data={ 'user': self.user.id, 'title': 'hola', 'text': 'amigos', }) self.assertTrue(serializer.is_valid()) # bug for python3 comes from here saved_object = serializer.save() self.assertEqual(saved_object.user, self.user) self.assertEqual(saved_object.title, 'hola') self.assertEqual(saved_object.text, 'amigos') def test_should_save_partial(self): serializer = CommentSerializer( instance=self.comment, data={'title': 'hola'}, partial=True) self.assertTrue(serializer.is_valid()) saved_object = serializer.save() self.assertEqual(saved_object.user, self.user) self.assertEqual(saved_object.title, 'hola') self.assertEqual(saved_object.text, 'world') def test_should_save_only_fields_from_data_for_partial_update(self): # it's important to use different instances for Comment, # because serializer's save method affects instance from arguments serializer_one = CommentSerializer( instance=self.get_comment(), data={'title': 'goodbye'}, partial=True) serializer_two = CommentSerializer( instance=self.get_comment(), data={'text': 'moon'}, partial=True) serializer_three_kwargs = { 'instance': self.get_comment(), 'partial': True } serializer_three_kwargs['data'] = {'attachment': self.files[1]} serializer_three = CommentSerializer(**serializer_three_kwargs) self.assertTrue(serializer_one.is_valid()) self.assertTrue(serializer_two.is_valid()) self.assertTrue(serializer_three.is_valid()) # saving three serializers expecting they don't affect each other's saving serializer_one.save() serializer_two.save() serializer_three.save() fresh_instance = self.get_comment() self.assertEqual(fresh_instance.attachment.read(), u'file two'.encode('utf-8')) fresh_instance.attachment.close() self.assertEqual(fresh_instance.text, 'moon') self.assertEqual(fresh_instance.title, 'goodbye') def test_should_use_related_field_name_for_update_field_list(self): another_user = UserModel.objects.create(name='vova') data = { 'title': 'goodbye', 'user': another_user.pk } serializer = CommentSerializer( instance=self.get_comment(), data=data, partial=True) self.assertTrue(serializer.is_valid()) serializer.save() fresh_instance = self.get_comment() self.assertEqual(fresh_instance.title, 'goodbye') self.assertEqual(fresh_instance.user, another_user) def test_should_use_field_source_value_for_searching_model_concrete_fields(self): data = { 'title_from_source': 'goodbye' } serializer = CommentSerializer( instance=self.get_comment(), data=data, partial=True) self.assertTrue(serializer.is_valid()) serializer.save() fresh_instance = self.get_comment() self.assertEqual(fresh_instance.title, 'goodbye') def test_should_not_use_m2m_field_name_for_update_field_list(self): another_user = UserModel.objects.create(name='vova') data = { 'title': 'goodbye', 'users_liked': [self.user.pk, another_user.pk] } serializer = CommentSerializer( instance=self.get_comment(), data=data, partial=True) self.assertTrue(serializer.is_valid()) try: serializer.save() except ValueError: self.fail( 'If m2m field used in partial update then it should not be used in update_fields list') fresh_instance = self.get_comment() self.assertEqual(fresh_instance.title, 'goodbye') users_liked = set( fresh_instance.users_liked.all().values_list('pk', flat=True)) self.assertEqual( users_liked, set([self.user.pk, another_user.pk])) def test_should_not_use_related_set_field_name_for_update_field_list(self): another_user = UserModel.objects.create(name='vova') another_comment = CommentModel.objects.create( user=another_user, title='goodbye', text='moon', ) data = { 'name': 'vova', 'comments': [another_comment.pk] } serializer = UserSerializer(instance=another_user, data=data, partial=True) self.assertTrue(serializer.is_valid()) serializer.save() try: serializer.save() except ValueError: self.fail('If related set field used in partial update then it should not be used in update_fields list') fresh_comment = CommentModel.objects.get(pk=another_comment.pk) fresh_user = UserModel.objects.get(pk=another_user.pk) self.assertEqual(fresh_comment.user, another_user) self.assertEqual(fresh_user.name, 'vova') def test_should_not_try_to_update_fields_that_are_not_in_model(self): data = { 'title': 'goodbye', 'not_existing_field': 'moon' } serializer = CommentSerializer(instance=self.get_comment(), data=data, partial=True) self.assertTrue(serializer.is_valid()) try: serializer.save() except ValueError: msg = 'Should not pass values to update_fields from data, if they are not in model' self.fail(msg) fresh_instance = self.get_comment() self.assertEqual(fresh_instance.title, 'goodbye') self.assertEqual(fresh_instance.text, 'world') def test_should_not_try_to_update_fields_that_are_not_allowed_from_serializer(self): data = { 'title': 'goodbye', 'hidden_text': 'do not change me' } serializer = CommentSerializer(instance=self.get_comment(), data=data, partial=True) self.assertTrue(serializer.is_valid()) serializer.save() fresh_instance = self.get_comment() self.assertEqual(fresh_instance.title, 'goodbye') self.assertEqual(fresh_instance.text, 'world') self.assertEqual(fresh_instance.hidden_text, None) def test_should_use_list_of_fields_to_update_from_arguments_if_it_passed(self): data = { 'title': 'goodbye', 'text': 'moon' } serializer = CommentSerializer(instance=self.get_comment(), data=data, partial=True) self.assertTrue(serializer.is_valid()) serializer.save(**{'update_fields': ['title']}) fresh_instance = self.get_comment() self.assertEqual(fresh_instance.title, 'goodbye') self.assertEqual(fresh_instance.text, 'world') def test_should_not_use_field_attname_for_update_fields__if_attname_not_allowed_in_serializer_fields(self): another_user = UserModel.objects.create(name='vova') data = { 'title': 'goodbye', 'user_id': another_user.id } serializer = CommentSerializer( instance=self.get_comment(), data=data, partial=True) self.assertTrue(serializer.is_valid()) serializer.save() fresh_instance = self.get_comment() self.assertEqual(fresh_instance.user_id, self.user.id) def test_should_use_field_attname_for_update_fields__if_attname_allowed_in_serializer_fields(self): another_user = UserModel.objects.create(name='vova') data = { 'title': 'goodbye', 'user_id': another_user.id } serializer = CommentSerializerWithAllowedUserId( instance=self.get_comment(), data=data, partial=True) self.assertTrue(serializer.is_valid()) serializer.save() fresh_instance = self.get_comment() self.assertEqual(fresh_instance.user_id, another_user.id) def test_should_not_use_pk_field_for_update_fields(self): old_pk = self.get_comment().pk data = { 'id': old_pk + 1, 'title': 'goodbye' } serializer = CommentSerializer( instance=self.get_comment(), data=data, partial=True) self.assertTrue(serializer.is_valid()) try: serializer.save() except ValueError: self.fail( 'Primary key field should be excluded from update_fields list') fresh_instance = self.get_comment() self.assertEqual(fresh_instance.pk, old_pk) self.assertEqual(fresh_instance.title, u'goodbye') drf-extensions-0.6.0/tests_app/tests/unit/utils/000077500000000000000000000000001361353156700217625ustar00rootroot00000000000000drf-extensions-0.6.0/tests_app/tests/unit/utils/__init__.py000066400000000000000000000000011361353156700240620ustar00rootroot00000000000000 drf-extensions-0.6.0/tests_app/tests/unit/utils/tests.py000066400000000000000000000026531361353156700235040ustar00rootroot00000000000000import contextlib try: from unittest import mock except ImportError: import mock from django.test import TestCase from rest_framework_extensions.utils import prepare_header_name, get_rest_framework_version @contextlib.contextmanager def parsed_version(version): with mock.patch('rest_framework.VERSION', version): yield get_rest_framework_version() class TestPrepareHeaderName(TestCase): def test_upper(self): self.assertEqual(prepare_header_name('Accept'), 'HTTP_ACCEPT') def test_replace_dash_with_underscores(self): self.assertEqual( prepare_header_name('Accept-Language'), 'HTTP_ACCEPT_LANGUAGE') def test_strips_whitespaces(self): self.assertEqual( prepare_header_name(' Accept-Language '), 'HTTP_ACCEPT_LANGUAGE') def test_adds_http_prefix(self): self.assertEqual( prepare_header_name('Accept-Language'), 'HTTP_ACCEPT_LANGUAGE') def test_get_rest_framework_version_exotic_version(self): """See """ with parsed_version('1.2alphaSOMETHING') as version: self.assertEqual(version, (1, 2, 'alpha', 'SOMETHING')) def test_get_rest_framework_version_normal_version(self): """See """ with parsed_version('3.14.16') as version: self.assertEqual(version, (3, 14, 16)) drf-extensions-0.6.0/tests_app/testutils.py000066400000000000000000000024621361353156700211170ustar00rootroot00000000000000import base64 from mock import patch from rest_framework import HTTP_HEADER_ENCODING from rest_framework.compat import get_regex_pattern from rest_framework_extensions.key_constructor import bits from rest_framework_extensions.key_constructor.constructors import KeyConstructor def get_url_pattern_by_regex_pattern(patterns, pattern_string): # todo: test me for pattern in patterns: if get_regex_pattern(pattern) == pattern_string: return pattern def override_extensions_api_settings(**kwargs): return patch.multiple( 'rest_framework_extensions.settings.extensions_api_settings', **kwargs ) def basic_auth_header(username, password): credentials = ('%s:%s' % (username, password)) base64_credentials = base64.b64encode( credentials.encode(HTTP_HEADER_ENCODING) ).decode(HTTP_HEADER_ENCODING) return 'Basic %s' % base64_credentials class TestFormatKeyBit(bits.KeyBitBase): def get_data(self, **kwargs): return u'json' class TestLanguageKeyBit(bits.KeyBitBase): def get_data(self, **kwargs): return u'ru' class TestUsedKwargsKeyBit(bits.KeyBitBase): def get_data(self, **kwargs): return kwargs class TestKeyConstructor(KeyConstructor): format = TestFormatKeyBit() language = TestLanguageKeyBit() drf-extensions-0.6.0/tests_app/urls.py000066400000000000000000000000211361353156700200310ustar00rootroot00000000000000urlpatterns = [] drf-extensions-0.6.0/tox.ini000066400000000000000000000014761361353156700160220ustar00rootroot00000000000000[tox] envlist = py{36,37}-django{111}-drf39, py{36,37}-django{21}-drf{39,310,311} py{36,37,38}-django{22}-drf{39,310,311} py{36,37,38}-django{30}-drf{310,311} [testenv] deps= -rtests_app/requirements.txt django-guardian>=1.4.4 drf39: djangorestframework>=3.9.3,<3.10 djangorestframework-guardian drf310: djangorestframework>=3.10,<3.11 djangorestframework-guardian drf311: djangorestframework>=3.11,<3.12 djangorestframework-guardian django111: Django>=1.11,<2.0 django21: Django>=2.1,<2.2 django22: Django>=2.2,<3.0 django30: Django>=3.0,<3.1 setenv = PYTHONPATH = {toxinidir}:{toxinidir}/tests_app commands = python --version pip freeze python -Wd {envbindir}/django-admin.py test --settings=settings {posargs}