pax_global_header00006660000000000000000000000064127731262460014524gustar00rootroot0000000000000052 comment=34817d40c2021513df1c7f49c8a2f06ce8b2549b drf-extensions-0.3.1/000077500000000000000000000000001277312624600144755ustar00rootroot00000000000000drf-extensions-0.3.1/.gitignore000066400000000000000000000000701277312624600164620ustar00rootroot00000000000000__pycache__/ *.pyc *.egg-info .tox *.egg .idea env dist drf-extensions-0.3.1/.travis.yml000066400000000000000000000003541277312624600166100ustar00rootroot00000000000000language: python python: - "3.5" - "3.4" - "2.7" env: - TOX_ENV=django.1.10 - TOX_ENV=django.1.9 - TOX_ENV=django.1.8.lts matrix: fast_finish: true install: - pip install tox script: - tox -e $TOX_ENV -- tests_appdrf-extensions-0.3.1/AUTHORS.md000066400000000000000000000003451277312624600161460ustar00rootroot00000000000000## Original Author --------------- Gennady Chibisov https://github.com/chibisov ## Core maintainer Asif Saifuddin https://github.com/auvipy ## Contributors ------------ Luke Murphy https://github.com/lwm drf-extensions-0.3.1/GNUmakefile000066400000000000000000000004061277312624600165470ustar00rootroot00000000000000build_docs: python docs/backdoc.py --source docs/index.md --title "Django Rest Framework extensions documentation" > docs/index.html python docs/post_process_docs.py watch_docs: make build_docs watchmedo shell-command -p "*.md" -R -c "make build_docs" docs/drf-extensions-0.3.1/LICENSE000066400000000000000000000020741277312624600155050ustar00rootroot00000000000000The 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.3.1/MANIFEST.in000066400000000000000000000003061277312624600162320ustar00rootroot00000000000000include 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.3.1/README.md000066400000000000000000000070041277312624600157550ustar00rootroot00000000000000## 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.png?branch=master)](https://travis-ci.org/chibisov/drf-extensions) [![Downloads](https://pypip.in/download/drf-extensions/badge.png)](https://pypi.python.org/pypi/drf-extensions/) [![Latest Version](https://pypip.in/version/drf-extensions/badge.png)](https://pypi.python.org/pypi/drf-extensions/) ## Requirements * Tested for python 2.7 and 3.4 versions * Tested for all releases of Django Rest Framework from 2.3.5 to 3.0.4 versions (pypi 0.2.8) * Tested for Django from 1.8 to 1.10 versions * master branch is supported with django 1.8 to 1.10 and drf 3.3.* + only ## Installation: pip install drf-extensions ## 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: $ pip install tox $ tox -- tests_app Running test for exact environment: $ tox -e py27-drf2.3.5 -- 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 drf-extensions-0.3.1/__init__.py000066400000000000000000000000001277312624600165740ustar00rootroot00000000000000drf-extensions-0.3.1/docs/000077500000000000000000000000001277312624600154255ustar00rootroot00000000000000drf-extensions-0.3.1/docs/backdoc.py000066400000000000000000006161731277312624600174030ustar00rootroot00000000000000# -*- coding: utf-8 -*- #!/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 # -*- coding: utf-8 -*- #!/usr/bin/env python # 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(object): # 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(object): """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(object): 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).encode('utf-8')) 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.3.1/docs/index.html000066400000000000000000005661741277312624600174450ustar00rootroot00000000000000 Django Rest Framework extensions documentation

DRF-extensions

DRF-extensions is a collection of custom extensions for Django REST Framework. Source repository is available at https://github.com/chibisov/drf-extensions.

Viewsets

Extensions for viewsets.

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 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 was introduced in 2.3.8 version.

Cache/ETAG mixins

ReadOnlyCacheResponseAndETAGMixin

This mixin combines ReadOnlyETAGMixin and CacheResponseMixin. It could be used with ReadOnlyModelViewSet and helps to process caching + etag calculation for retrieve and list methods:

from myapps.serializers import UserSerializer
from rest_framework_extensions.mixins import (
    ReadOnlyCacheResponseAndETAGMixin
)

class UserViewSet(ReadOnlyCacheResponseAndETAGMixin,
                  viewsets.ReadOnlyModelViewSet):
    serializer_class = UserSerializer

CacheResponseAndETAGMixin

This mixin combines ETAGMixin and CacheResponseMixin. It could be used with ModelViewSet and helps to process:

  • Caching for retrieve and list methods
  • Etag for retrieve, list, update and destroy methods

Usage:

from myapps.serializers import UserSerializer
from rest_framework_extensions.mixins import CacheResponseAndETAGMixin

class UserViewSet(CacheResponseAndETAGMixin,
                  viewsets.ModelViewSet):
    serializer_class = UserSerializer

Please, read more about caching, key construction and conditional requests.

Routers

Extensions for routers.

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.

For example:

from rest_framework_extensions.routers import ExtendedSimpleRouter
from yourapp.views import (
    UserViewSet,
    GroupViewSet,
    PermissionViewSet,
)

router = ExtendedSimpleRouter()
(
    router.register(r'users', UserViewSet, base_name='user')
          .register(r'groups',
                    GroupViewSet,
                    base_name='users-group',
                    parents_query_lookups=['user_groups'])
          .register(r'permissions',
                    PermissionViewSet,
                    base_name='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/<pk>/ - user detail. Resolve name is user-detail
  • /users/<parent_lookup_user_groups>/groups/ - list of groups for exact user. Resolve name is users-group-list
  • /users/<parent_lookup_user_groups>/groups/<pk>/ - user group detail. If user doesn't have group then resource will be not found. Resolve name is users-group-detail
  • /users/<parent_lookup_group__user>/groups/<parent_lookup_group>/permissions/ - list of permissions for user group. Resolve name is users-groups-permission-list
  • /users/<parent_lookup_group__user>/groups/<parent_lookup_group>/permissions/<pk>/ - 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,
    base_name='permission'
)
permissions_routes.register(
    r'groups',
    GroupViewSet,
    base_name='permissions-group',
    parents_query_lookups=['permissions']
)
permissions_routes.register(
    r'users',
    UserViewSet,
    base_name='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/<pk>/ - permission detail. Resolve name is permission-detail
  • /permissions/<parent_lookup_permissions>/groups/ - list of groups for exact permission. Resolve name is permissions-group-list
  • /permissions/<parent_lookup_permissions>/groups/<pk>/ - permission group detail. If group doesn't have permission then resource will be not found. Resolve name is permissions-group-detail
  • /permissions/<parent_lookup_groups__permissions>/users/ - list of users for exact permission. Resolve name is permissions-user-list
  • /permissions/<parent_lookup_groups__permissions>/user/<pk>/ - 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 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 functionality.

PartialUpdateSerializerMixin

New in DRF-extensions 0.2.3

By default every saving of 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. 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 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.

Object permissions

New in DRF-extensions 0.2.2

Django Rest Framework allows you to use 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. 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 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.

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 cache_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 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.

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 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 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:

  • "DEFAULT_OBJECT_CACHE_KEY_FUNC" for retrieve method
  • "DEFAULT_LIST_CACHE_KEY_FUNC" for list method

By default those functions are using DefaultKeyConstructor 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',
}

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'

Ofcourse you can use custom 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()

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 constructor

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

<?xml version="1.0" encoding="utf-8"?>
<root>
    <list-item>Moscow</list-item>
    <list-item>London</list-item>
    <list-item>Paris</list-item>
</root>

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 that default key calculation? 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 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 next 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 and key bits used like form fields. Lets go through key construction steps for DefaultKeyConstructor.

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_text
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_text(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 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_COUNTRY'])

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):
    user = 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 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 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 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()

Conditional requests

This documentation section uses information from RESTful Web Services Cookbook 10-th chapter.

Conditional HTTP request allows API clients to accomplish 2 goals:

  • Conditional HTTP GET saves client and server time and bandwidth.
  • For unsafe requests such as PUT, POST, and DELETE, conditional requests provide concurrency control.

HTTP Etag

An ETag or entity tag, is part of HTTP, the protocol for the World Wide Web. It is one of several mechanisms that HTTP provides for web cache validation, and which allows a client to make conditional requests. - Wikipedia

For etag calculation and conditional request processing you should use rest_framework_extensions.etag.decorators.etag decorator. It's similar to native django decorator.

from rest_framework_extensions.etag.decorators import etag

class CityView(views.APIView):
    @etag()
    def get(self, request, *args, **kwargs):
        cities = City.objects.all().values_list('name', flat=True)
        return Response(cities)

By default @etag would calculate header value with the same algorithm as cache key default calculation performs.

# Request
GET /cities/ HTTP/1.1
Accept: application/json

# Response
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
ETag: "e7b50490dc546d116635a14cfa58110306dd6c5434146b6740ec08bf0a78f9a2"

['Moscow', 'London', 'Paris']

You can define custom function for Etag value calculation with etag_func argument:

from rest_framework_extensions.etag.decorators import etag

def calculate_etag(view_instance, view_method,
                   request, args, kwargs):
    return '.'.join([
        len(args),
        len(kwargs)
    ])

class CityView(views.APIView):
    @etag(etag_func=calculate_etag)
    def get(self, request, *args, **kwargs):
        cities = City.objects.all().values_list('name', flat=True)
        return Response(cities)

You can implement view method and use it for Etag calculation by specifying etag_func argument as string:

from rest_framework_extensions.etag.decorators import etag

class CityView(views.APIView):
    @etag(etag_func='calculate_etag_from_method')
    def get(self, request, *args, **kwargs):
        cities = City.objects.all().values_list('name', flat=True)
        return Response(cities)

    def calculate_etag_from_method(self, view_instance, view_method,
                                   request, args, kwargs):
        return '.'.join([
            len(args),
            len(kwargs)
        ])

Etag 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 etag function

If @etag decorator used without etag_func argument then default etag function will be used. You can change this function in settings:

REST_FRAMEWORK_EXTENSIONS = {
    'DEFAULT_ETAG_FUNC':
      'rest_framework_extensions.utils.default_etag_func'
}

default_etag_func uses DefaultKeyConstructor as a base for etag calculation.

Usage with caching

As you can see @etag and @cache_response decorators has similar key calculation approaches. They both can take key from simple callable function. And more then this - in many cases they share the same calculation logic. In the next example we use both decorators, which share one calculation function:

from rest_framework_extensions.etag.decorators import etag
from rest_framework_extensions.cache.decorators import cache_response
from rest_framework_extensions.key_constructor import bits
from rest_framework_extensions.key_constructor.constructors import (
    KeyConstructor
)

class CityGetKeyConstructor(KeyConstructor):
    format = bits.FormatKeyBit()
    language = bits.LanguageKeyBit()

class CityView(views.APIView):
    key_constructor_func = CityGetKeyConstructor()

    @etag(key_constructor_func)
    @cache_response(key_func=key_constructor_func)
    def get(self, request, *args, **kwargs):
        cities = City.objects.all().values_list('name', flat=True)
        return Response(cities)

Note the decorators order. First goes @etag and after goes @cache_response. We want firstly perform conditional processing and after it response processing.

There is one more point for it. If conditional processing didn't fail then key_constructor_func would be called again in @cache_response. But in most cases first calculation is enough. To accomplish this goal you could use KeyConstructor initial argument memoize_for_request:

>>> key_constructor_func = CityGetKeyConstructor(memoize_for_request=True)
>>> request1, request1 = 'request1', 'request2'
>>> print key_constructor_func(request=request1)  # full calculation
request1-key
>>> print key_constructor_func(request=request1)  # data from cache
request1-key
>>> print key_constructor_func(request=request2)  # full calculation
request2-key
>>> print key_constructor_func(request=request2)  # data from cache
request2-key

By default memoize_for_request is False, but you can change it in settings:

REST_FRAMEWORK_EXTENSIONS = {
    'DEFAULT_KEY_CONSTRUCTOR_MEMOIZE_FOR_REQUEST': True
}

It's important to note that this memoization is thread safe.

Saving time and bandwith

When a server returns ETag header, you should store it along with the representation data on the client. When making GET and HEAD requests for the same resource in the future, include the If-None-Match header to make these requests "conditional".

For example, retrieve all cities:

# Request
GET /cities/ HTTP/1.1
Accept: application/json

# Response
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
ETag: "some_etag_value"

['Moscow', 'London', 'Paris']

If you make same request with If-None-Match and there is the cached value for this request, then server will respond with 304 status code without body data.

# Request
GET /cities/ HTTP/1.1
Accept: application/json
If-None-Match: some_etag_value

# Response
HTTP/1.1 304 NOT MODIFIED
Content-Type: application/json; charset=UTF-8
Etag: "some_etag_value"

After this response you can use existing cities data on the client.

Concurrency control

Concurrency control ensures the correct processing of data under concurrent operations by clients. There are two ways to implement concurrency control:

  • Pessimistic concurrency control. In this model, the client gets a lock, obtains the current state of the resource, makes modifications, and then releases the lock. During this process, the server prevents other clients from acquiring a lock on the same resource. Relational databases operate in this manner.
  • Optimistic concurrency control. In this model, the client first gets a token. Instead of obtaining a lock, the client attempts a write operation with the token included in the request. The operation succeeds if the token is still valid and fails otherwise.

HTTP, being a stateless application control, is designed for optimistic concurrency control.

                                PUT
                                 |
                          +-------------+
                          |  Etag       |
                          |  supplied?  |
                          +-------------+
                           |           |
                          Yes          No
                           |           |
        +--------------------+       +-----------------------+
        |  Do preconditions  |       |  Does the             |
        |  match?            |       |  resource exist?      |
        +--------------------+       +-----------------------+
            |           |                   |              |
            Yes         No                  Yes            No
            |           |                   |              |
+--------------+  +--------------------+  +-------------+  |
|  Update the  |  |  412 Precondition  |  |  403        |  |
|  resource    |  |  failed            |  |  Forbidden  |  |
+--------------+  +--------------------+  +-------------+  |
                                                           |
                                     +-----------------------+
                                     |  Can clients          |
                                     |  create resources     |
                                     +-----------------------+
                                           |           |
                                          Yes          No
                                           |           |
                                     +-----------+   +-------------+
                                     |  201      |   |  404        |
                                     |  Created  |   |  Not Found  |
                                     +-----------+   +-------------+

Delete:

                               DELETE
                                 |
                          +-------------+
                          |  Etag       |
                          |  supplied?  |
                          +-------------+
                           |           |
                          Yes          No
                           |           |
        +--------------------+       +-------------+
        |  Do preconditions  |       |  403        |
        |  match?            |       |  Forbidden  |
        +--------------------+       +-------------+
            |           |
            Yes         No
            |           |
+--------------+  +--------------------+
|  Delete the  |  |  412 Precondition  |
|  resource    |  |  failed            |
+--------------+  +--------------------+

Here is example of implementation for all CRUD methods (except create, because it doesn't need concurrency control) wrapped with etag decorator:

from rest_framework.viewsets import ModelViewSet
from rest_framework_extensions.key_constructor import bits
from rest_framework_extensions.key_constructor.constructors import (
    KeyConstructor
)

from your_app.models import City
from your_app.key_bits import UpdatedAtKeyBit

class CityListKeyConstructor(KeyConstructor):
    format = bits.FormatKeyBit()
    language = bits.LanguageKeyBit()
    pagination = bits.PaginationKeyBit()
    list_sql_query = bits.ListSqlQueryKeyBit()
    unique_view_id = bits.UniqueViewIdKeyBit()

class CityDetailKeyConstructor(KeyConstructor):
    format = bits.FormatKeyBit()
    language = bits.LanguageKeyBit()
    retrieve_sql_query = bits.RetrieveSqlQueryKeyBit()
    unique_view_id = bits.UniqueViewIdKeyBit()
    updated_at = UpdatedAtKeyBit()

class CityViewSet(ModelViewSet):
    list_key_func = CityListKeyConstructor(
        memoize_for_request=True
    )
    obj_key_func = CityDetailKeyConstructor(
        memoize_for_request=True
    )

    @etag(list_key_func)
    @cache_response(key_func=list_key_func)
    def list(self, request, *args, **kwargs):
        return super(CityViewSet, self).list(request, *args, **kwargs)

    @etag(obj_key_func)
    @cache_response(key_func=obj_key_func)
    def retrieve(self, request, *args, **kwargs):
        return super(CityViewSet, self).retrieve(request, *args, **kwargs)

    @etag(obj_key_func)
    def update(self, request, *args, **kwargs):
        return super(CityViewSet, self).update(request, *args, **kwargs)

    @etag(obj_key_func)
    def destroy(self, request, *args, **kwargs):
        return super(CityViewSet, self).destroy(request, *args, **kwargs)

Etag for unsafe methods

From previous section you could see that unsafe methods, such update (PUT, PATCH) or destroy (DELETE), have the same @etag decorator wrapping manner as the safe methods.

But every unsafe method has one distinction from safe method - it changes the data which could be used for Etag calculation. In our case it is UpdatedAtKeyBit. It means that we should calculate Etag:

  • Before building response - for If-Match and If-None-Match conditions validation
  • After building response (if necessary) - for clients

@etag decorator has special attribute rebuild_after_method_evaluation, which by default is False.

If you specify rebuild_after_method_evaluation as True then Etag will be rebuilt after method evaluation:

class CityViewSet(ModelViewSet):
    ...
    @etag(obj_key_func, rebuild_after_method_evaluation=True)
    def update(self, request, *args, **kwargs):
        return super(CityViewSet, self).update(request, *args, **kwargs)

    @etag(obj_key_func)
    def destroy(self, request, *args, **kwargs):
        return super(CityViewSet, self).destroy(request, *args, **kwargs)

# Request
PUT /cities/1/ HTTP/1.1
Accept: application/json

{"name": "London"}

# Response
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
ETag: "4e63ef056f47270272b96523f51ad938b5ea141024b767880eac047d10a0b339"

{
  id: 1,
  name: "London"
}

As you can see we didn't specify rebuild_after_method_evaluation for destroy method. That is because there is no sense to use returned Etag value on clients if object deletion already performed.

With rebuild_after_method_evaluation parameter Etag calculation for PUT/PATCH method would look like:

             +--------------+
             |    Request   |
             +--------------+
                    |
       +--------------------------+
       |  Calculate Etag          |
       |  for condition matching  |
       +--------------------------+
                    |
          +--------------------+
          |  Do preconditions  |
          |  match?            |
          +--------------------+
              |           |
              Yes         No
              |           |
  +--------------+  +--------------------+
  |  Update the  |  |  412 Precondition  |
  |  resource    |  |  failed            |
  +--------------+  +--------------------+
         |
+--------------------+
|  Calculate Etag    |
|  again and add it  |
|  to response       |
+--------------------+
         |
   +------------+
   |  Return    |
   |  response  |
   +------------+

If-None-Match example for DELETE method:

# Request
DELETE /cities/1/ HTTP/1.1
Accept: application/json
If-None-Match: some_etag_value

# Response
HTTP/1.1 304 NOT MODIFIED
Content-Type: application/json; charset=UTF-8
Etag: "some_etag_value"

If-Match example for DELETE method:

# Request
DELETE /cities/1/ HTTP/1.1
Accept: application/json
If-Match: another_etag_value

# Response
HTTP/1.1 412 PRECONDITION FAILED
Content-Type: application/json; charset=UTF-8
Etag: "some_etag_value"

ETAGMixin

It is common to process etags for standard viewset retrieve, list, update and destroy methods. That is why ETAGMixin exists. Just mix it into viewset implementation and those methods will use functions, defined in REST_FRAMEWORK_EXTENSIONS settings:

  • "DEFAULT_OBJECT_ETAG_FUNC" for retrieve, update and destroy methods
  • "DEFAULT_LIST_ETAG_FUNC" for list method

By default those functions are using DefaultKeyConstructor and extends it:

  • With RetrieveSqlQueryKeyBit for "DEFAULT_OBJECT_ETAG_FUNC"
  • With ListSqlQueryKeyBit and PaginationKeyBit for "DEFAULT_LIST_ETAG_FUNC"

You can change those settings for custom etag generation:

REST_FRAMEWORK_EXTENSIONS = {
    'DEFAULT_OBJECT_ETAG_FUNC':
      'rest_framework_extensions.utils.default_object_etag_func',
    'DEFAULT_LIST_ETAG_FUNC':
      'rest_framework_extensions.utils.default_list_etag_func',
}

Mixin example usage:

from myapps.serializers import UserSerializer
from rest_framework_extensions.etag.mixins import ETAGMixin

class UserViewSet(ETAGMixin, viewsets.ModelViewSet):
    serializer_class = UserSerializer

You can change etag function by providing object_etag_func or list_etag_func methods in view class:

class UserViewSet(ETAGMixin, viewsets.ModelViewSet):
    serializer_class = UserSerializer

    def object_etag_func(self, **kwargs):
        return 'some key for object'

    def list_etag_func(self, **kwargs):
        return 'some key for list'

Ofcourse you can use custom key constructor:

from yourapp.key_constructors import (
    CustomObjectKeyConstructor,
    CustomListKeyConstructor,
)

class UserViewSet(ETAGMixin, viewsets.ModelViewSet):
    serializer_class = UserSerializer
    object_etag_func = CustomObjectKeyConstructor()
    list_etag_func = CustomListKeyConstructor()

It is important to note that etags for unsafe method update is processed with parameter rebuild_after_method_evaluation equals True. You can read why from this section.

There are other mixins for more granular Etag calculation in rest_framework_extensions.etag.mixins module:

  • ReadOnlyETAGMixin - only for retrieve and list methods
  • RetrieveETAGMixin - only for retrieve method
  • ListETAGMixin - only for list method
  • DestroyETAGMixin - only for destroy method
  • UpdateETAGMixin - only for update method

Gzipped ETags

If you use GZipMiddleware and your client accepts Gzipped response, then you should return different ETags for compressed and not compressed responses. That's what GZipMiddleware does by default while processing response - it adds ;gzip postfix to ETag response header if client requests compressed response. Lets see it in example. First request without compression:

# Request
GET /cities/ HTTP/1.1
Accept: application/json

# Response
HTTP/1.1 200 OK
Content-Type: application/json; charset=UTF-8
ETag: "e7b50490dc"

['Moscow', 'London', 'Paris']

Second request with compression:

# Request
GET /cities/ HTTP/1.1
Accept: application/json
Accept-Encoding: gzip

# Response
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 675
Content-Encoding: gzip
ETag: "e7b50490dc;gzip"

wS?n?0?_%o?cc?Ҫ?Eʒ?Cժʻ?a\1?a?^T*7q<>[Nvh?[?^9?x:/Ms?79?Fd/???ۦjES?ڽ?&??c%^?C[K۲%N?w{?졭2?m?}?Q&Egz??

As you can see there is ;gzip postfix in ETag response header. That's ok but there is one caveat - drf-extension doesn't know how you post-processed calculated ETag value. And your clients could have next problem with conditional request:

  • Client sends request to retrieve compressed data about cities to /cities/
  • DRF-extensions decorator calculates ETag header for response equals, for example, 123
  • GZipMiddleware adds ;gzip postfix to ETag header response, and now it equals 123;gzip
  • Client retrieves response with ETag equals 123;gzip
  • Client again makes request to retrieve compressed data about cities, but now it's conditional request with If-None-Match header equals 123;gzip
  • DRF-extensions decorator calculates ETag value for processing conditional request. But it doesn't know, that GZipMiddleware added ;gzip postfix for previous response. DRF-extensions decorator calculates ETag equals 123, compares it with 123;gzip and returns response with status code 200, because 123 != 123;gzip

You can solve this problem by stripping ;gzip postfix on client side.

But there are so many libraries that just magically uses ETag response header without allowing to pre-process conditional requests (for example, browser). If that's you case then you could add custom middleware which removes ;gzip postfix from header:

# yourapp/middleware.py

class RemoveEtagGzipPostfix(object):
    def process_response(self, request, response):
        if response.has_header('ETag') and response['ETag'][-6:] == ';gzip"':
            response['ETag'] = response['ETag'][:-6] + '"'
        return response

Don't forget to add this middleware in your settings before GZipMiddleware:

# settings.py
MIDDLEWARE_CLASSES = (
    ...
    'yourapp.RemoveEtagGzipPostfix',
    'django.middleware.gzip.GZipMiddleware',
    'django.middleware.common.CommonMiddleware',
    ...
)

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 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 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.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-extesions follows Django Rest Framework approach in settings implementation.

In Django Rest Framework 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-extesions 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-exteinsions 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.

0.3.1

Sep 29, 2016

  • Fix schema_urls ExtendedDefaultRouter compatability 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

0.2.7

Feb 2, 2015

0.2.6

Sep 9, 2014

0.2.5

July 9, 2014

0.2.4

July 7, 2014

0.2.3

Apr. 25, 2014

0.2.2

Mar. 23, 2014

0.2.1

Feb. 1, 2014

0.2

Nov. 5, 2013

  • Moved docs from readme to github pages
  • Docs generation with Backdoc
Яндекс.Метрикаdrf-extensions-0.3.1/docs/index.md000066400000000000000000002245651277312624600170740ustar00rootroot00000000000000### 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 **ReadOnlyCacheResponseAndETAGMixin** This mixin combines `ReadOnlyETAGMixin` and `CacheResponseMixin`. It could be used with [ReadOnlyModelViewSet](http://www.django-rest-framework.org/api-guide/viewsets.html#readonlymodelviewset) and helps to process caching + etag calculation for `retrieve` and `list` methods: from myapps.serializers import UserSerializer from rest_framework_extensions.mixins import ( ReadOnlyCacheResponseAndETAGMixin ) class UserViewSet(ReadOnlyCacheResponseAndETAGMixin, viewsets.ReadOnlyModelViewSet): serializer_class = UserSerializer **CacheResponseAndETAGMixin** This mixin combines `ETAGMixin` and `CacheResponseMixin`. It could be used with [ModelViewSet](http://www.django-rest-framework.org/api-guide/viewsets.html#modelviewset) and helps to process: * Caching for `retrieve` and `list` methods * Etag for `retrieve`, `list`, `update` and `destroy` methods Usage: from myapps.serializers import UserSerializer from rest_framework_extensions.mixins import CacheResponseAndETAGMixin class UserViewSet(CacheResponseAndETAGMixin, viewsets.ModelViewSet): serializer_class = UserSerializer Please, read more about [caching](#caching), [key construction](#key-constructor) and [conditional requests](#conditional-requests). ### 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, base_name='user') .register(r'groups', GroupViewSet, base_name='users-group', parents_query_lookups=['user_groups']) .register(r'permissions', PermissionViewSet, base_name='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, base_name='permission' ) permissions_routes.register( r'groups', GroupViewSet, base_name='permissions-group', parents_query_lookups=['permissions'] ) permissions_routes.register( r'users', UserViewSet, base_name='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. #### 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 `cache_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', } 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' Ofcourse 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() 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 constructor 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 that [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 next 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_text 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_text(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_COUNTRY']) 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): user = 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() ### Conditional requests *This documentation section uses information from [RESTful Web Services Cookbook](http://shop.oreilly.com/product/9780596801694.do) 10-th chapter.* Conditional HTTP request allows API clients to accomplish 2 goals: * Conditional HTTP GET saves client and server time and bandwidth. * For unsafe requests such as PUT, POST, and DELETE, conditional requests provide concurrency control. #### HTTP Etag *An ETag or entity tag, is part of HTTP, the protocol for the World Wide Web. It is one of several mechanisms that HTTP provides for web cache validation, and which allows a client to make conditional requests.* - [Wikipedia](http://en.wikipedia.org/wiki/HTTP_ETag) For etag calculation and conditional request processing you should use `rest_framework_extensions.etag.decorators.etag` decorator. It's similar to native [django decorator](https://docs.djangoproject.com/en/dev/topics/conditional-view-processing/). from rest_framework_extensions.etag.decorators import etag class CityView(views.APIView): @etag() def get(self, request, *args, **kwargs): cities = City.objects.all().values_list('name', flat=True) return Response(cities) By default `@etag` would calculate header value with the same algorithm as [cache key](#cache-key) default calculation performs. # Request GET /cities/ HTTP/1.1 Accept: application/json # Response HTTP/1.1 200 OK Content-Type: application/json; charset=UTF-8 ETag: "e7b50490dc546d116635a14cfa58110306dd6c5434146b6740ec08bf0a78f9a2" ['Moscow', 'London', 'Paris'] You can define custom function for Etag value calculation with `etag_func` argument: from rest_framework_extensions.etag.decorators import etag def calculate_etag(view_instance, view_method, request, args, kwargs): return '.'.join([ len(args), len(kwargs) ]) class CityView(views.APIView): @etag(etag_func=calculate_etag) def get(self, request, *args, **kwargs): cities = City.objects.all().values_list('name', flat=True) return Response(cities) You can implement view method and use it for Etag calculation by specifying `etag_func` argument as string: from rest_framework_extensions.etag.decorators import etag class CityView(views.APIView): @etag(etag_func='calculate_etag_from_method') def get(self, request, *args, **kwargs): cities = City.objects.all().values_list('name', flat=True) return Response(cities) def calculate_etag_from_method(self, view_instance, view_method, request, args, kwargs): return '.'.join([ len(args), len(kwargs) ]) Etag 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 etag function If `@etag` decorator used without `etag_func` argument then default etag function will be used. You can change this function in settings: REST_FRAMEWORK_EXTENSIONS = { 'DEFAULT_ETAG_FUNC': 'rest_framework_extensions.utils.default_etag_func' } `default_etag_func` uses [DefaultKeyConstructor](#default-key-constructor) as a base for etag calculation. #### Usage with caching As you can see `@etag` and `@cache_response` decorators has similar key calculation approaches. They both can take key from simple callable function. And more then this - in many cases they share the same calculation logic. In the next example we use both decorators, which share one calculation function: from rest_framework_extensions.etag.decorators import etag from rest_framework_extensions.cache.decorators import cache_response from rest_framework_extensions.key_constructor import bits from rest_framework_extensions.key_constructor.constructors import ( KeyConstructor ) class CityGetKeyConstructor(KeyConstructor): format = bits.FormatKeyBit() language = bits.LanguageKeyBit() class CityView(views.APIView): key_constructor_func = CityGetKeyConstructor() @etag(key_constructor_func) @cache_response(key_func=key_constructor_func) def get(self, request, *args, **kwargs): cities = City.objects.all().values_list('name', flat=True) return Response(cities) Note the decorators order. First goes `@etag` and after goes `@cache_response`. We want firstly perform conditional processing and after it response processing. There is one more point for it. If conditional processing didn't fail then `key_constructor_func` would be called again in `@cache_response`. But in most cases first calculation is enough. To accomplish this goal you could use `KeyConstructor` initial argument `memoize_for_request`: >>> key_constructor_func = CityGetKeyConstructor(memoize_for_request=True) >>> request1, request1 = 'request1', 'request2' >>> print key_constructor_func(request=request1) # full calculation request1-key >>> print key_constructor_func(request=request1) # data from cache request1-key >>> print key_constructor_func(request=request2) # full calculation request2-key >>> print key_constructor_func(request=request2) # data from cache request2-key By default `memoize_for_request` is `False`, but you can change it in settings: REST_FRAMEWORK_EXTENSIONS = { 'DEFAULT_KEY_CONSTRUCTOR_MEMOIZE_FOR_REQUEST': True } It's important to note that this memoization is thread safe. #### Saving time and bandwith When a server returns `ETag` header, you should store it along with the representation data on the client. When making GET and HEAD requests for the same resource in the future, include the `If-None-Match` header to make these requests "conditional". For example, retrieve all cities: # Request GET /cities/ HTTP/1.1 Accept: application/json # Response HTTP/1.1 200 OK Content-Type: application/json; charset=UTF-8 ETag: "some_etag_value" ['Moscow', 'London', 'Paris'] If you make same request with `If-None-Match` and there is the cached value for this request, then server will respond with `304` status code without body data. # Request GET /cities/ HTTP/1.1 Accept: application/json If-None-Match: some_etag_value # Response HTTP/1.1 304 NOT MODIFIED Content-Type: application/json; charset=UTF-8 Etag: "some_etag_value" After this response you can use existing cities data on the client. #### Concurrency control Concurrency control ensures the correct processing of data under concurrent operations by clients. There are two ways to implement concurrency control: * **Pessimistic concurrency control**. In this model, the client gets a lock, obtains the current state of the resource, makes modifications, and then releases the lock. During this process, the server prevents other clients from acquiring a lock on the same resource. Relational databases operate in this manner. * **Optimistic concurrency control**. In this model, the client first gets a token. Instead of obtaining a lock, the client attempts a write operation with the token included in the request. The operation succeeds if the token is still valid and fails otherwise. HTTP, being a stateless application control, is designed for optimistic concurrency control. PUT | +-------------+ | Etag | | supplied? | +-------------+ | | Yes No | | +--------------------+ +-----------------------+ | Do preconditions | | Does the | | match? | | resource exist? | +--------------------+ +-----------------------+ | | | | Yes No Yes No | | | | +--------------+ +--------------------+ +-------------+ | | Update the | | 412 Precondition | | 403 | | | resource | | failed | | Forbidden | | +--------------+ +--------------------+ +-------------+ | | +-----------------------+ | Can clients | | create resources | +-----------------------+ | | Yes No | | +-----------+ +-------------+ | 201 | | 404 | | Created | | Not Found | +-----------+ +-------------+ Delete: DELETE | +-------------+ | Etag | | supplied? | +-------------+ | | Yes No | | +--------------------+ +-------------+ | Do preconditions | | 403 | | match? | | Forbidden | +--------------------+ +-------------+ | | Yes No | | +--------------+ +--------------------+ | Delete the | | 412 Precondition | | resource | | failed | +--------------+ +--------------------+ Here is example of implementation for all CRUD methods (except create, because it doesn't need concurrency control) wrapped with `etag` decorator: from rest_framework.viewsets import ModelViewSet from rest_framework_extensions.key_constructor import bits from rest_framework_extensions.key_constructor.constructors import ( KeyConstructor ) from your_app.models import City from your_app.key_bits import UpdatedAtKeyBit class CityListKeyConstructor(KeyConstructor): format = bits.FormatKeyBit() language = bits.LanguageKeyBit() pagination = bits.PaginationKeyBit() list_sql_query = bits.ListSqlQueryKeyBit() unique_view_id = bits.UniqueViewIdKeyBit() class CityDetailKeyConstructor(KeyConstructor): format = bits.FormatKeyBit() language = bits.LanguageKeyBit() retrieve_sql_query = bits.RetrieveSqlQueryKeyBit() unique_view_id = bits.UniqueViewIdKeyBit() updated_at = UpdatedAtKeyBit() class CityViewSet(ModelViewSet): list_key_func = CityListKeyConstructor( memoize_for_request=True ) obj_key_func = CityDetailKeyConstructor( memoize_for_request=True ) @etag(list_key_func) @cache_response(key_func=list_key_func) def list(self, request, *args, **kwargs): return super(CityViewSet, self).list(request, *args, **kwargs) @etag(obj_key_func) @cache_response(key_func=obj_key_func) def retrieve(self, request, *args, **kwargs): return super(CityViewSet, self).retrieve(request, *args, **kwargs) @etag(obj_key_func) def update(self, request, *args, **kwargs): return super(CityViewSet, self).update(request, *args, **kwargs) @etag(obj_key_func) def destroy(self, request, *args, **kwargs): return super(CityViewSet, self).destroy(request, *args, **kwargs) #### Etag for unsafe methods From previous section you could see that unsafe methods, such `update` (PUT, PATCH) or `destroy` (DELETE), have the same `@etag` decorator wrapping manner as the safe methods. But every unsafe method has one distinction from safe method - it changes the data which could be used for Etag calculation. In our case it is `UpdatedAtKeyBit`. It means that we should calculate Etag: * Before building response - for `If-Match` and `If-None-Match` conditions validation * After building response (if necessary) - for clients `@etag` decorator has special attribute `rebuild_after_method_evaluation`, which by default is `False`. If you specify `rebuild_after_method_evaluation` as `True` then Etag will be rebuilt after method evaluation: class CityViewSet(ModelViewSet): ... @etag(obj_key_func, rebuild_after_method_evaluation=True) def update(self, request, *args, **kwargs): return super(CityViewSet, self).update(request, *args, **kwargs) @etag(obj_key_func) def destroy(self, request, *args, **kwargs): return super(CityViewSet, self).destroy(request, *args, **kwargs) # Request PUT /cities/1/ HTTP/1.1 Accept: application/json {"name": "London"} # Response HTTP/1.1 200 OK Content-Type: application/json; charset=UTF-8 ETag: "4e63ef056f47270272b96523f51ad938b5ea141024b767880eac047d10a0b339" { id: 1, name: "London" } As you can see we didn't specify `rebuild_after_method_evaluation` for `destroy` method. That is because there is no sense to use returned Etag value on clients if object deletion already performed. With `rebuild_after_method_evaluation` parameter Etag calculation for `PUT`/`PATCH` method would look like: +--------------+ | Request | +--------------+ | +--------------------------+ | Calculate Etag | | for condition matching | +--------------------------+ | +--------------------+ | Do preconditions | | match? | +--------------------+ | | Yes No | | +--------------+ +--------------------+ | Update the | | 412 Precondition | | resource | | failed | +--------------+ +--------------------+ | +--------------------+ | Calculate Etag | | again and add it | | to response | +--------------------+ | +------------+ | Return | | response | +------------+ `If-None-Match` example for `DELETE` method: # Request DELETE /cities/1/ HTTP/1.1 Accept: application/json If-None-Match: some_etag_value # Response HTTP/1.1 304 NOT MODIFIED Content-Type: application/json; charset=UTF-8 Etag: "some_etag_value" `If-Match` example for `DELETE` method: # Request DELETE /cities/1/ HTTP/1.1 Accept: application/json If-Match: another_etag_value # Response HTTP/1.1 412 PRECONDITION FAILED Content-Type: application/json; charset=UTF-8 Etag: "some_etag_value" #### ETAGMixin It is common to process etags for standard [viewset](http://www.django-rest-framework.org/api-guide/viewsets) `retrieve`, `list`, `update` and `destroy` methods. That is why `ETAGMixin` exists. Just mix it into viewset implementation and those methods will use functions, defined in `REST_FRAMEWORK_EXTENSIONS` [settings](#settings): * *"DEFAULT\_OBJECT\_ETAG\_FUNC"* for `retrieve`, `update` and `destroy` methods * *"DEFAULT\_LIST\_ETAG\_FUNC"* for `list` method By default those functions are using [DefaultKeyConstructor](#default-key-constructor) and extends it: * With `RetrieveSqlQueryKeyBit` for *"DEFAULT\_OBJECT\_ETAG\_FUNC"* * With `ListSqlQueryKeyBit` and `PaginationKeyBit` for *"DEFAULT\_LIST\_ETAG\_FUNC"* You can change those settings for custom etag generation: REST_FRAMEWORK_EXTENSIONS = { 'DEFAULT_OBJECT_ETAG_FUNC': 'rest_framework_extensions.utils.default_object_etag_func', 'DEFAULT_LIST_ETAG_FUNC': 'rest_framework_extensions.utils.default_list_etag_func', } Mixin example usage: from myapps.serializers import UserSerializer from rest_framework_extensions.etag.mixins import ETAGMixin class UserViewSet(ETAGMixin, viewsets.ModelViewSet): serializer_class = UserSerializer You can change etag function by providing `object_etag_func` or `list_etag_func` methods in view class: class UserViewSet(ETAGMixin, viewsets.ModelViewSet): serializer_class = UserSerializer def object_etag_func(self, **kwargs): return 'some key for object' def list_etag_func(self, **kwargs): return 'some key for list' Ofcourse you can use custom [key constructor](#key-constructor): from yourapp.key_constructors import ( CustomObjectKeyConstructor, CustomListKeyConstructor, ) class UserViewSet(ETAGMixin, viewsets.ModelViewSet): serializer_class = UserSerializer object_etag_func = CustomObjectKeyConstructor() list_etag_func = CustomListKeyConstructor() It is important to note that etags for unsafe method `update` is processed with parameter `rebuild_after_method_evaluation` equals `True`. You can read why from [this](#etag-for-unsafe-methods) section. There are other mixins for more granular Etag calculation in `rest_framework_extensions.etag.mixins` module: * **ReadOnlyETAGMixin** - only for `retrieve` and `list` methods * **RetrieveETAGMixin** - only for `retrieve` method * **ListETAGMixin** - only for `list` method * **DestroyETAGMixin** - only for `destroy` method * **UpdateETAGMixin** - only for `update` method #### Gzipped ETags If you use [GZipMiddleware](https://docs.djangoproject.com/en/dev/ref/middleware/#module-django.middleware.gzip) and your client accepts Gzipped response, then you should return different ETags for compressed and not compressed responses. That's what `GZipMiddleware` does by default while processing response - it adds `;gzip` postfix to ETag response header if client requests compressed response. Lets see it in example. First request without compression: # Request GET /cities/ HTTP/1.1 Accept: application/json # Response HTTP/1.1 200 OK Content-Type: application/json; charset=UTF-8 ETag: "e7b50490dc" ['Moscow', 'London', 'Paris'] Second request with compression: # Request GET /cities/ HTTP/1.1 Accept: application/json Accept-Encoding: gzip # Response HTTP/1.1 200 OK Content-Type: application/json Content-Length: 675 Content-Encoding: gzip ETag: "e7b50490dc;gzip" wS?n?0?_%o?cc?Ҫ?Eʒ?Cժʻ?a\1?a?^T*7q<>[Nvh?[?^9?x:/Ms?79?Fd/???ۦjES?ڽ?&??c%^?C[K۲%N?w{?졭2?m?}?Q&Egz?? As you can see there is `;gzip` postfix in ETag response header. That's ok but there is one caveat - drf-extension doesn't know how you post-processed calculated ETag value. And your clients could have next problem with conditional request: * Client sends request to retrieve compressed data about cities to `/cities/` * DRF-extensions decorator calculates ETag header for response equals, for example, `123` * `GZipMiddleware` adds `;gzip` postfix to ETag header response, and now it equals `123;gzip` * Client retrieves response with ETag equals `123;gzip` * Client again makes request to retrieve compressed data about cities, but now it's conditional request with `If-None-Match` header equals `123;gzip` * DRF-extensions decorator calculates ETag value for processing conditional request. But it doesn't know, that `GZipMiddleware` added `;gzip` postfix for previous response. DRF-extensions decorator calculates ETag equals `123`, compares it with `123;gzip` and returns response with status code 200, because `123` != `123;gzip` You can solve this problem by stripping `;gzip` postfix on client side. But there are so many libraries that just magically uses ETag response header without allowing to pre-process conditional requests (for example, browser). If that's you case then you could add custom middleware which removes `;gzip` postfix from header: # yourapp/middleware.py class RemoveEtagGzipPostfix(object): def process_response(self, request, response): if response.has_header('ETag') and response['ETag'][-6:] == ';gzip"': response['ETag'] = response['ETag'][:-6] + '"' return response Don't forget to add this middleware in your settings before `GZipMiddleware`: # settings.py MIDDLEWARE_CLASSES = ( ... 'yourapp.RemoveEtagGzipPostfix', 'django.middleware.gzip.GZipMiddleware', 'django.middleware.common.CommonMiddleware', ... ) ### 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.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-extesions 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-extesions 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-exteinsions 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](http://django-rest-framework.org/topics/release-notes). #### 0.3.1 *Sep 29, 2016* * Fix `schema_urls` `ExtendedDefaultRouter` compatability 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.3.1/docs/metrika_code.txt000066400000000000000000000026451277312624600206230ustar00rootroot00000000000000Яндекс.Метрикаdrf-extensions-0.3.1/docs/post_process_docs.py000066400000000000000000000004261277312624600215340ustar00rootroot00000000000000docs_file_path = 'docs/index.html' docs_file_content = open(docs_file_path, 'r').read()[:-len('')] docs_file = open(docs_file_path, 'w') metrika_file_content = open('docs/metrika_code.txt', 'r').read() docs_file.write(docs_file_content + metrika_file_content + '')drf-extensions-0.3.1/rest_framework_extensions/000077500000000000000000000000001277312624600220065ustar00rootroot00000000000000drf-extensions-0.3.1/rest_framework_extensions/__init__.py000066400000000000000000000000551277312624600241170ustar00rootroot00000000000000__version__ = '0.3.1' VERSION = __version__ drf-extensions-0.3.1/rest_framework_extensions/bulk_operations/000077500000000000000000000000001277312624600252065ustar00rootroot00000000000000drf-extensions-0.3.1/rest_framework_extensions/bulk_operations/__init__.py000066400000000000000000000000011277312624600273060ustar00rootroot00000000000000 drf-extensions-0.3.1/rest_framework_extensions/bulk_operations/mixins.py000066400000000000000000000077511277312624600271010ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.utils.encoding import force_text 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(object): 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(ListDestroyModelMixin, self).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(ListUpdateModelMixin, self).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) self.pre_save_bulk(queryset, update_bulk_dict) # todo: test and document me try: queryset.update(**update_bulk_dict) except ValueError as e: errors = { 'detail': force_text(e) } return Response(errors, status=status.HTTP_400_BAD_REQUEST) self.post_save_bulk(queryset, update_bulk_dict) # todo: test and document me 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.3.1/rest_framework_extensions/cache/000077500000000000000000000000001277312624600230515ustar00rootroot00000000000000drf-extensions-0.3.1/rest_framework_extensions/cache/__init__.py000066400000000000000000000000001277312624600251500ustar00rootroot00000000000000drf-extensions-0.3.1/rest_framework_extensions/cache/decorators.py000066400000000000000000000057611277312624600256010ustar00rootroot00000000000000# -*- coding: utf-8 -*- from functools import wraps from django.utils.decorators import available_attrs from rest_framework_extensions.settings import extensions_api_settings from django.utils import six def get_cache(alias): from django.core.cache import caches return caches[alias] class CacheResponse(object): 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=available_attrs(func)) 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 ) response = self.cache.get(key) if not response: response = view_method(view_instance, request, *args, **kwargs) response = view_instance.finalize_response(request, response, *args, **kwargs) response.render() # should be rendered, before picklining while storing to cache if not response.status_code >= 400 or self.cache_errors: self.cache.set(key, response, self.timeout) 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, six.string_types): 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, ) cache_response = CacheResponse drf-extensions-0.3.1/rest_framework_extensions/cache/mixins.py000066400000000000000000000021161277312624600247320ustar00rootroot00000000000000# -*- coding: utf-8 -*- from rest_framework_extensions.cache.decorators import cache_response from rest_framework_extensions.settings import extensions_api_settings class BaseCacheResponseMixin(object): # 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 class ListCacheResponseMixin(BaseCacheResponseMixin): @cache_response(key_func='list_cache_key_func') def list(self, request, *args, **kwargs): return super(ListCacheResponseMixin, self).list(request, *args, **kwargs) class RetrieveCacheResponseMixin(BaseCacheResponseMixin): @cache_response(key_func='object_cache_key_func') def retrieve(self, request, *args, **kwargs): return super(RetrieveCacheResponseMixin, self).retrieve(request, *args, **kwargs) class CacheResponseMixin(RetrieveCacheResponseMixin, ListCacheResponseMixin): passdrf-extensions-0.3.1/rest_framework_extensions/compat.py000066400000000000000000000120331277312624600236420ustar00rootroot00000000000000""" The `compat` module provides support for backwards compatibility with older versions of django/python, and compatibility wrappers around optional packages. """ # flake8: noqa from __future__ import unicode_literals import django import inspect from django.core.exceptions import ImproperlyConfigured from django.conf import settings from django.utils.encoding import python_2_unicode_compatible # Try to import six from Django, fallback to included `six`. from django.utils import six # location of patterns, url, include changes in 1.8 onwards from django.conf.urls import url, include from django.utils.encoding import smart_text from django.utils.encoding import force_text # HttpResponseBase only exists from 1.5 onwards from django.http.response import HttpResponseBase # django-filter is optional try: import django_filters except ImportError: django_filters = None # guardian is optional try: import guardian except ImportError: guardian = None # cStringIO only if it's available, otherwise StringIO try: import cStringIO.StringIO as StringIO except ImportError: StringIO = six.StringIO BytesIO = six.BytesIO # urlparse compat import (Required because it changed in python 3.x) try: from urllib import parse as urlparse except ImportError: import urlparse # UserDict moves in Python 3 try: from UserDict import UserDict from UserDict import DictMixin except ImportError: from collections import UserDict from collections import MutableMapping as DictMixin # Try to import PIL in either of the two ways it can end up installed. try: from PIL import Image except ImportError: try: import Image except ImportError: Image = None def get_model_name(model_cls): return model_cls._meta.model_name def get_concrete_model(model_cls): return model_cls._meta.concrete_model # PUT, DELETE do not require CSRF until 1.4. They should.Make it better. from django.middleware.csrf import CsrfViewMiddleware # timezone support is new in Django 1.4 try: from django.utils import timezone except ImportError: timezone = None # dateparse is ALSO new in Django 1.4 from django.utils.dateparse import parse_date, parse_datetime, parse_time # smart_urlquote is new on Django 1.4 from django.utils.html import smart_urlquote # RequestFactory only provide `generic` from 1.5 onwards from django.test.client import RequestFactory from django.test.client import FakePayload # Markdown is optional try: import markdown def apply_markdown(text): """ Simple wrapper around :func:`markdown.markdown` to set the base level of '#' style headers to

. """ extensions = ['headerid(level=2)'] safe_mode = False md = markdown.Markdown(extensions=extensions, safe_mode=safe_mode) return md.convert(text) except ImportError: apply_markdown = None # Yaml is optional try: import yaml except ImportError: yaml = None # XML is optional try: import defusedxml.ElementTree as etree except ImportError: etree = None # OAuth is optional try: # Note: The `oauth2` package actually provides oauth1.0a support. Urg. import oauth2 as oauth except ImportError: oauth = None # OAuth is optional try: import oauth_provider from oauth_provider.store import store as oauth_provider_store # check_nonce's calling signature in django-oauth-plus changes sometime # between versions 2.0 and 2.2.1 def check_nonce(request, oauth_request, oauth_nonce, oauth_timestamp): check_nonce_args = inspect.getargspec(oauth_provider_store.check_nonce).args if 'timestamp' in check_nonce_args: return oauth_provider_store.check_nonce( request, oauth_request, oauth_nonce, oauth_timestamp ) return oauth_provider_store.check_nonce( request, oauth_request, oauth_nonce ) except (ImportError, ImproperlyConfigured): oauth_provider = None oauth_provider_store = None check_nonce = None # OAuth 2 support is optional try: import provider as oauth2_provider from provider import scope as oauth2_provider_scope from provider import constants as oauth2_constants if oauth2_provider.__version__ in ('0.2.3', '0.2.4'): # 0.2.3 and 0.2.4 are supported version that do not support # timezone aware datetimes import datetime provider_now = datetime.datetime.now else: # Any other supported version does use timezone aware datetimes from django.utils.timezone import now as provider_now except ImportError: oauth2_provider = None oauth2_provider_scope = None oauth2_constants = None provider_now = None # Handle lazy strings from django.utils.functional import Promise if six.PY3: def is_non_str_iterable(obj): if (isinstance(obj, str) or (isinstance(obj, Promise) and obj._delegate_text)): return False return hasattr(obj, '__iter__') else: def is_non_str_iterable(obj): return hasattr(obj, '__iter__') drf-extensions-0.3.1/rest_framework_extensions/compat_drf.py000066400000000000000000000013051277312624600244750ustar00rootroot00000000000000""" The `compat` module provides support for backwards compatibility with older versions of Django REST Framework. """ from rest_framework_extensions.utils import get_rest_framework_features def add_trailing_slash_if_needed(regexp_string): # todo: test me if get_rest_framework_features()['router_trailing_slash']: return regexp_string[:-2] + '{trailing_slash}$' else: return regexp_string def get_lookup_allowed_symbols(kwarg_name='pk', force_dot=False): # todo: test me if get_rest_framework_features()['use_dot_in_lookup_regex_by_default'] or force_dot: return '(?P<{0}>[^/.]+)'.format(kwarg_name) else: return '(?P<{0}>[^/]+)'.format(kwarg_name)drf-extensions-0.3.1/rest_framework_extensions/etag/000077500000000000000000000000001277312624600227265ustar00rootroot00000000000000drf-extensions-0.3.1/rest_framework_extensions/etag/__init__.py000066400000000000000000000000011277312624600250260ustar00rootroot00000000000000 drf-extensions-0.3.1/rest_framework_extensions/etag/decorators.py000066400000000000000000000117211277312624600254470ustar00rootroot00000000000000# -*- coding: utf-8 -*- import logging from functools import wraps from django.utils.decorators import available_attrs 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.utils import prepare_header_name from rest_framework_extensions.settings import extensions_api_settings from django.utils import six logger = logging.getLogger('django.request') class ETAGProcessor(object): """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=available_attrs(func)) 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, six.string_types): 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: 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_200_OK, 'request': request } ) return Response(status=status.HTTP_412_PRECONDITION_FAILED) etag = ETAGProcessordrf-extensions-0.3.1/rest_framework_extensions/etag/mixins.py000066400000000000000000000030071277312624600246070ustar00rootroot00000000000000# -*- coding: utf-8 -*- from rest_framework_extensions.etag.decorators import etag from rest_framework_extensions.settings import extensions_api_settings class BaseETAGMixin(object): # 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(ListETAGMixin, self).list(request, *args, **kwargs) class RetrieveETAGMixin(BaseETAGMixin): @etag(etag_func='object_etag_func') def retrieve(self, request, *args, **kwargs): return super(RetrieveETAGMixin, self).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(UpdateETAGMixin, self).update(request, *args, **kwargs) class DestroyETAGMixin(BaseETAGMixin): @etag(etag_func='object_etag_func') def destroy(self, request, *args, **kwargs): return super(DestroyETAGMixin, self).destroy(request, *args, **kwargs) class ReadOnlyETAGMixin(RetrieveETAGMixin, ListETAGMixin): pass class ETAGMixin(RetrieveETAGMixin, UpdateETAGMixin, DestroyETAGMixin, ListETAGMixin): pass drf-extensions-0.3.1/rest_framework_extensions/fields.py000066400000000000000000000014231277312624600236260ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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(ResourceUriField, self).__init__(*args, **kwargs)drf-extensions-0.3.1/rest_framework_extensions/key_constructor/000077500000000000000000000000001277312624600252435ustar00rootroot00000000000000drf-extensions-0.3.1/rest_framework_extensions/key_constructor/__init__.py000066400000000000000000000000001277312624600273420ustar00rootroot00000000000000drf-extensions-0.3.1/rest_framework_extensions/key_constructor/bits.py000066400000000000000000000151071277312624600265620ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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_text class AllArgsMixin(object): def __init__(self, params='*'): super(AllArgsMixin, self).__init__(params) class KeyBitBase(object): 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_text(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 u'.'.join([ view_instance.__module__, view_instance.__class__.__name__ ]) class UniqueMethodIdKeyBit(KeyBitBase): def get_data(self, params, view_instance, view_method, request, args, kwargs): return u'.'.join([ view_instance.__module__, view_instance.__class__.__name__, view_method.__name__ ]) class LanguageKeyBit(KeyBitBase): """ Return example: u'en' """ def get_data(self, params, view_instance, view_method, request, args, kwargs): return force_text(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_text(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_text(self._get_id_from_user(request.user)) else: return u'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 return prepare_header_name(key.lower()) # Accept-Language => http_accept_language 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'} """ def get_data(self, **kwargs): kwargs['params'] = [] if hasattr(kwargs['view_instance'], 'paginator'): if hasattr(kwargs['view_instance'].paginator, 'page_query_param'): kwargs['params'].append( kwargs['view_instance'].paginator.page_query_param) if hasattr(kwargs['view_instance'].paginator, 'page_size_query_param'): kwargs['params'].append( kwargs['view_instance'].paginator.page_size_query_param) return super(PaginationKeyBit, self).get_data(**kwargs) class SqlQueryKeyBitBase(KeyBitBase): def _get_queryset_query_string(self, queryset): if isinstance(queryset, EmptyQuerySet): return None else: try: return force_text(queryset.query.__str__()) 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 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.3.1/rest_framework_extensions/key_constructor/constructors.py000066400000000000000000000072471277312624600303770ustar00rootroot00000000000000# -*- coding: utf-8 -*- import hashlib import json from rest_framework_extensions.key_constructor import bits from rest_framework_extensions.settings import extensions_api_settings class KeyConstructor(object): 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() drf-extensions-0.3.1/rest_framework_extensions/mixins.py000066400000000000000000000061041277312624600236700ustar00rootroot00000000000000# -*- coding: utf-8 -*- # Try to import six from Django, fallback to included `six`. from django.utils import six from rest_framework_extensions.cache.mixins import CacheResponseMixin from rest_framework_extensions.etag.mixins import ReadOnlyETAGMixin, ETAGMixin from rest_framework_extensions.utils import get_rest_framework_features from rest_framework_extensions.bulk_operations.mixins import ListUpdateModelMixin from rest_framework_extensions.settings import extensions_api_settings from django.http import Http404 class DetailSerializerMixin(object): """ 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(DetailSerializerMixin, self).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(DetailSerializerMixin, self).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(object): 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(PaginateByMaxMixin, self).get_page_size(request) class ReadOnlyCacheResponseAndETAGMixin(ReadOnlyETAGMixin, CacheResponseMixin): pass class CacheResponseAndETAGMixin(ETAGMixin, CacheResponseMixin): pass class NestedViewSetMixin(object): def get_queryset(self): return self.filter_queryset_by_parents_lookups( super(NestedViewSetMixin, self).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 six.iteritems(self.kwargs): 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.3.1/rest_framework_extensions/models.py000066400000000000000000000000001277312624600236310ustar00rootroot00000000000000drf-extensions-0.3.1/rest_framework_extensions/permissions/000077500000000000000000000000001277312624600243615ustar00rootroot00000000000000drf-extensions-0.3.1/rest_framework_extensions/permissions/__init__.py000066400000000000000000000003721277312624600264740ustar00rootroot00000000000000# -*- coding: utf-8 -*- from rest_framework_extensions.utils import get_rest_framework_features if get_rest_framework_features()['django_object_permissions_class']: from .extended_django_object_permissions import ExtendedDjangoObjectPermissionsdrf-extensions-0.3.1/rest_framework_extensions/permissions/extended_django_object_permissions.py000066400000000000000000000014351277312624600340410ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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(ExtendedDjangoObjectPermissions, self).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.3.1/rest_framework_extensions/routers.py000066400000000000000000000221141277312624600240630ustar00rootroot00000000000000# -*- coding: utf-8 -*- from distutils.version import StrictVersion from django.core.exceptions import ImproperlyConfigured from django.core.urlresolvers import NoReverseMatch import rest_framework from rest_framework.routers import ( DefaultRouter, SimpleRouter, Route, replace_methodname, ) from rest_framework import views from rest_framework.reverse import reverse from rest_framework.response import Response from rest_framework_extensions.utils import flatten, compose_parent_pk_kwarg_name from rest_framework_extensions.compat_drf import add_trailing_slash_if_needed class ExtendedActionLinkRouterMixin(object): routes = [ # List route. Route( url=add_trailing_slash_if_needed(r'^{prefix}/$'), mapping={ 'get': 'list', 'post': 'create' }, name='{basename}-list', initkwargs={'suffix': 'List'} ), # Detail route. Route( url=add_trailing_slash_if_needed(r'^{prefix}/{lookup}/$'), mapping={ 'get': 'retrieve', 'put': 'update', 'patch': 'partial_update', 'delete': 'destroy' }, name='{basename}-detail', initkwargs={'suffix': 'Instance'} ), # Dynamically generated routes. # Generated using @list_route or @detail_route decorators on methods of the viewset. # List Route( url=add_trailing_slash_if_needed(r'^{prefix}/{methodname}/$'), mapping={ '{httpmethod}': '{methodname}', }, name='{basename}-{methodnamehyphen}-list', initkwargs={} ), # Detail Route( url=add_trailing_slash_if_needed(r'^{prefix}/{lookup}/{methodname}/$'), mapping={ '{httpmethod}': '{methodname}', }, name='{basename}-{methodnamehyphen}', initkwargs={} ), ] _routs = routes[2:4] + routes[:2] # first routes should be dynamic (because of urlpatterns position matters) # left self.routs for backward def get_routes(self, viewset): """ Augment `self.routes` with any dynamically generated routes. Returns a list of the Route namedtuple. """ # Determine any `@list_route` or `@detail_route` decorated methods on the viewset dynamic_routes = self.get_dynamic_routes(viewset) ret = [] for route in self._routs: if self.is_dynamic_route(route): # Dynamic routes (@list_route or @detail_route decorator) if self.is_list_dynamic_route(route): ret += self.get_dynamic_routes_instances( viewset, route, self._filter_by_list_dynamic_routes(dynamic_routes) ) else: ret += self.get_dynamic_routes_instances( viewset, route, self._filter_by_detail_dynamic_routes(dynamic_routes) ) else: # Standard route ret.append(route) return ret def _filter_by_list_dynamic_routes(self, dynamic_routes): return [i for i in dynamic_routes if i[3]] def _filter_by_detail_dynamic_routes(self, dynamic_routes): return [i for i in dynamic_routes if not i[3]] def get_dynamic_routes(self, viewset): known_actions = self.get_known_actions() dynamic_routes = [] for methodname in dir(viewset): attr = getattr(viewset, methodname) httpmethods = getattr(attr, 'bind_to_methods', None) if httpmethods: endpoint = getattr(attr, 'endpoint', methodname) is_for_list = getattr(attr, 'is_for_list', not getattr(attr, 'detail', True)) if endpoint in known_actions: raise ImproperlyConfigured('Cannot use @detail_route or @list_route decorator on ' 'method "%s" as %s is an existing route' % (methodname, endpoint)) httpmethods = [method.lower() for method in httpmethods] dynamic_routes.append((httpmethods, methodname, endpoint, is_for_list)) return dynamic_routes def get_dynamic_route_viewset_method_name_by_endpoint(self, viewset, endpoint): for dynamic_route in self.get_dynamic_routes(viewset=viewset): if dynamic_route[2] == endpoint: return dynamic_route[1] def get_known_actions(self): return flatten([route.mapping.values() for route in self.routes]) def is_dynamic_route(self, route): return route.mapping == {'{httpmethod}': '{methodname}'} def is_list_dynamic_route(self, route): return route.name == '{basename}-{methodnamehyphen}-list' def get_dynamic_routes_instances(self, viewset, route, dynamic_routes): dynamic_routes_instances = [] for httpmethods, methodname, endpoint, is_for_list in dynamic_routes: initkwargs = route.initkwargs.copy() initkwargs.update(getattr(viewset, methodname).kwargs) url_path = initkwargs.pop('url_path', endpoint) dynamic_routes_instances.append(Route( url=replace_methodname(route.url, url_path), mapping=dict((httpmethod, methodname) for httpmethod in httpmethods), name=replace_methodname(route.name, url_path), initkwargs=initkwargs, )) return dynamic_routes_instances class NestedRegistryItem(object): 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, base_name, parents_query_lookups): self.router._register( prefix=self.get_prefix(current_prefix=prefix, parents_query_lookups=parents_query_lookups), viewset=viewset, base_name=base_name, ) 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 parent_lookup_value_regex = getattr(self.parent_viewset, 'lookup_value_regex', '[^/.]+') while current_item: 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(object): def _register(self, *args, **kwargs): return super(NestedRouterMixin, self).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] ) def get_api_root_view(self, **kwargs): """ Return a view to use as the API root. Important to maintain compat with DRF 3.4.0 """ if StrictVersion(rest_framework.VERSION) >= StrictVersion('3.4.0'): return super(NestedRouterMixin, self).get_api_root_view(**kwargs) if StrictVersion(rest_framework.VERSION) >= StrictVersion('2.4.3'): return super(NestedRouterMixin, self).get_api_root_view() api_root_dict = {} list_name = self.routes[0].name for prefix, viewset, basename in self.registry: api_root_dict[prefix] = list_name.format(basename=basename) class APIRoot(views.APIView): _ignore_model_permissions = True def get(self, request, format=None): ret = {} for key, url_name in api_root_dict.items(): try: ret[key] = reverse(url_name, request=request, format=format) except NoReverseMatch: pass return Response(ret) return APIRoot.as_view() class ExtendedRouterMixin(ExtendedActionLinkRouterMixin, NestedRouterMixin): pass class ExtendedSimpleRouter(ExtendedRouterMixin, SimpleRouter): pass class ExtendedDefaultRouter(ExtendedRouterMixin, DefaultRouter): pass drf-extensions-0.3.1/rest_framework_extensions/serializers.py000066400000000000000000000033151277312624600247160ustar00rootroot00000000000000# -*- coding: utf-8 -*- from rest_framework_extensions.compat import get_concrete_model from rest_framework_extensions.utils import get_model_opts_concrete_fields def get_fields_for_partial_update(opts, init_data, fields, init_files=None): opts = get_concrete_model(opts.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(object): def save(self, **kwargs): self._update_fields = kwargs.get('update_fields', None) return super(PartialUpdateSerializerMixin, self).save(**kwargs) def update(self, instance, validated_attrs): for attr, value in validated_attrs.items(): 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.3.1/rest_framework_extensions/settings.py000066400000000000000000000025271277312624600242260ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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', # 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', ] extensions_api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS)drf-extensions-0.3.1/rest_framework_extensions/test.py000066400000000000000000000144771277312624600233540ustar00rootroot00000000000000# -- coding: utf-8 -- # Note that we import as `DjangoRequestFactory` and `DjangoClient` in order # to make it harder for the user to import the wrong thing without realizing. from __future__ import unicode_literals 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, six # 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(APIRequestFactory, self).__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, six.text_type): 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(APIRequestFactory, self).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(ForceAuthClientHandler, self).__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(ForceAuthClientHandler, self).get_response(request) class APIClient(APIRequestFactory, DjangoClient): def __init__(self, enforce_csrf_checks=False, **defaults): super(APIClient, self).__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(APIClient, self).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 = APIClientdrf-extensions-0.3.1/rest_framework_extensions/utils.py000066400000000000000000000053241277312624600235240ustar00rootroot00000000000000# -*- coding: utf-8 -*- import itertools from functools import wraps from django import VERSION as django_version import rest_framework from rest_framework_extensions.key_constructor.constructors import ( DefaultKeyConstructor, DefaultObjectKeyConstructor, DefaultListKeyConstructor, ) from rest_framework_extensions.settings import extensions_api_settings def get_rest_framework_features(): return { 'router_trailing_slash': get_rest_framework_version() >= (2, 3, 6), 'allow_dot_in_lookup_regex_without_trailing_slash': get_rest_framework_version() >= (2, 3, 8), 'use_dot_in_lookup_regex_by_default': get_rest_framework_version() >= (2, 4, 0), 'max_paginate_by': get_rest_framework_version() >= (2, 3, 8), 'django_object_permissions_class': get_rest_framework_version() >= (2, 3, 8), 'write_only_fields': get_rest_framework_version() >= (2, 3, 11), 'uses_single_request_data_in_serializers': get_rest_framework_version() >= (3, 0), 'allows_to_send_custom_kwargs_for_saving_object_in_serializers': get_rest_framework_version() <= (3, 0), 'uses_single_request_data_in_serializers': get_rest_framework_version() >= (3, 0), } def get_django_features(): # todo: test me return { 'caches_singleton': django_version >= (1, 7, 0) } def get_rest_framework_version(): return tuple(map(int, rest_framework.VERSION.split('.'))) 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 u'.'.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 drf-extensions-0.3.1/setup.cfg000066400000000000000000000000341277312624600163130ustar00rootroot00000000000000[bdist_wheel] universal = 1 drf-extensions-0.3.1/setup.py000066400000000000000000000050141277312624600162070ustar00rootroot00000000000000#!/usr/bin/env python # -*- coding: utf-8 -*- 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.3.1'], description='Extensions for Django REST Framework', long_description='DRF-extensions is a collection of custom extensions for Django REST Framework', author='Gennady Chibisov', author_email='web-chib@ya.ru', 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.3.1/test000066400000000000000000000000001277312624600153650ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/000077500000000000000000000000001277312624600164775ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/__init__.py000066400000000000000000000000001277312624600205760ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/apps.py000066400000000000000000000001261277312624600200130ustar00rootroot00000000000000from django.apps import AppConfig class TestsApp(AppConfig): name = 'tests_app' drf-extensions-0.3.1/tests_app/models.py000066400000000000000000000000001277312624600203220ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/plugins.py000066400000000000000000000041021277312624600205270ustar00rootroot00000000000000# -*- coding: utf-8 -*- import 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.FILE_STORAGE_DIR): os.makedirs(settings.FILE_STORAGE_DIR) def finalize(self, result): shutil.rmtree(settings.FILE_STORAGE_DIR, 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_rundrf-extensions-0.3.1/tests_app/requirements.txt000066400000000000000000000000501277312624600217560ustar00rootroot00000000000000nose django-nose django-filter mock ipdbdrf-extensions-0.3.1/tests_app/settings.py000066400000000000000000000100371277312624600207120ustar00rootroot00000000000000# Django settings for testproject project. import os import multiprocessing BASE_PATH = os.path.dirname(os.path.normpath(__file__)) FILE_STORAGE_DIR = os.path.join(BASE_PATH, 'tests_file_storage', str(os.getpid())) DEBUG = True TEMPLATE_DEBUG = DEBUG DEBUG_PROPAGATE_EXCEPTIONS = True ALLOWED_HOSTS = ['*'] ADMINS = ( # ('Your Name', 'your_email@domain.com'), ) MANAGERS = ADMINS DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': 'test', '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 = '' # 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. TEMPLATE_LOADERS = ( 'django.template.loaders.filesystem.Loader', 'django.template.loaders.app_directories.Loader', ) MIDDLEWARE_CLASSES = ( 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', ) 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.apps.TestsApp', ) STATIC_URL = '/static/' PASSWORD_HASHERS = ( 'django.contrib.auth.hashers.SHA1PasswordHasher', 'django.contrib.auth.hashers.PBKDF2PasswordHasher', 'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher', 'django.contrib.auth.hashers.BCryptPasswordHasher', 'django.contrib.auth.hashers.MD5PasswordHasher', 'django.contrib.auth.hashers.CryptPasswordHasher', ) AUTH_USER_MODEL = 'auth.User' import django 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.3.1/tests_app/tests/000077500000000000000000000000001277312624600176415ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/__init__.py000066400000000000000000000000001277312624600217400ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/000077500000000000000000000000001277312624600220035ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/__init__.py000066400000000000000000000000001277312624600241020ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/cache/000077500000000000000000000000001277312624600230465ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/cache/__init__.py000066400000000000000000000000011277312624600251460ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/functional/cache/decorators/000077500000000000000000000000001277312624600252135ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/cache/decorators/__init__.py000066400000000000000000000000011277312624600273130ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/functional/cache/decorators/tests.py000066400000000000000000000011451277312624600267300ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.test import TestCase, override_settings from django.utils.encoding import force_text @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_text(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.3.1/tests_app/tests/functional/cache/decorators/urls.py000066400000000000000000000002441277312624600265520ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.conf.urls import url from .views import HelloView urlpatterns = [ url(r'^hello/$', HelloView.as_view(), name='hello'), ] drf-extensions-0.3.1/tests_app/tests/functional/cache/decorators/views.py000066400000000000000000000004701277312624600267230ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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.3.1/tests_app/tests/functional/examples/000077500000000000000000000000001277312624600236215ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/examples/__init__.py000066400000000000000000000000011277312624600257210ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/functional/examples/etags/000077500000000000000000000000001277312624600247245ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/examples/etags/__init__.py000066400000000000000000000000011277312624600270240ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/functional/examples/etags/remove_etag_gzip_postfix/000077500000000000000000000000001277312624600320265ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/examples/etags/remove_etag_gzip_postfix/__init__.py000066400000000000000000000000011277312624600341260ustar00rootroot00000000000000 middleware.py000066400000000000000000000004221277312624600344340ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/examples/etags/remove_etag_gzip_postfix# -*- coding: utf-8 -*- class RemoveEtagGzipPostfix(object): def process_response(self, request, response): if response.has_header('ETag') and response['ETag'][-6:] == ';gzip"': response['ETag'] = response['ETag'][:-6] + '"' return responsedrf-extensions-0.3.1/tests_app/tests/functional/examples/etags/remove_etag_gzip_postfix/tests.py000066400000000000000000000022511277312624600335420ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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.3.1/tests_app/tests/functional/examples/etags/remove_etag_gzip_postfix/urls.py000066400000000000000000000002431277312624600333640ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.conf.urls import url from .views import MyView urlpatterns = [ url(r'^remove-etag-gzip-postfix/$', MyView.as_view()), ] drf-extensions-0.3.1/tests_app/tests/functional/examples/etags/remove_etag_gzip_postfix/views.py000066400000000000000000000011211277312624600335300ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.views.generic 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 responsedrf-extensions-0.3.1/tests_app/tests/functional/key_constructor/000077500000000000000000000000001277312624600252405ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/key_constructor/__init__.py000066400000000000000000000000011277312624600273400ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/functional/key_constructor/bits/000077500000000000000000000000001277312624600262015ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/key_constructor/bits/__init__.py000066400000000000000000000000011277312624600303010ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/functional/key_constructor/bits/models.py000066400000000000000000000005401277312624600300350ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.db import models class KeyConstructorUserProperty(models.Model): name = models.CharField(max_length=100) class Meta: app_label = 'tests_app' class KeyConstructorUserModel(models.Model): property = models.ForeignKey(KeyConstructorUserProperty) class Meta: app_label = 'tests_app'drf-extensions-0.3.1/tests_app/tests/functional/key_constructor/bits/serializers.py000066400000000000000000000003361277312624600311110ustar00rootroot00000000000000# -*- coding: utf-8 -*- from rest_framework import serializers from .models import KeyConstructorUserModel class UserModelSerializer(serializers.ModelSerializer): class Meta: model = KeyConstructorUserModel drf-extensions-0.3.1/tests_app/tests/functional/key_constructor/bits/tests.py000066400000000000000000000025731277312624600277240ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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, 200) # retrieve response = self.client.get('/users/1/?property=1') self.assertEqual(response.status_code, 404) drf-extensions-0.3.1/tests_app/tests/functional/key_constructor/bits/urls.py000066400000000000000000000003401277312624600275350ustar00rootroot00000000000000# -*- coding: utf-8 -*- from rest_framework import routers from .views import UserModelViewSet viewset_router = routers.DefaultRouter() viewset_router.register('users', UserModelViewSet) urlpatterns = viewset_router.urls drf-extensions-0.3.1/tests_app/tests/functional/key_constructor/bits/views.py000066400000000000000000000010401277312624600277030ustar00rootroot00000000000000# -*- coding: utf-8 -*- from rest_framework import viewsets from rest_framework_extensions.etag.mixins import ListETAGMixin, RetrieveETAGMixin from rest_framework.filters import DjangoFilterBackend from .models import KeyConstructorUserModel as UserModel from .serializers import UserModelSerializer class UserModelViewSet(ListETAGMixin, RetrieveETAGMixin, viewsets.ModelViewSet): queryset = UserModel.objects.all() serializer_class = UserModelSerializer filter_backends = (DjangoFilterBackend,) filter_fields = ('property',)drf-extensions-0.3.1/tests_app/tests/functional/mixins/000077500000000000000000000000001277312624600233125ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/mixins/__init__.py000066400000000000000000000000001277312624600254110ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/mixins/detail_serializer_mixin/000077500000000000000000000000001277312624600302115ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/mixins/detail_serializer_mixin/__init__.py000066400000000000000000000000011277312624600323110ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/functional/mixins/detail_serializer_mixin/models.py000066400000000000000000000004111277312624600320420ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.db import models class Comment(models.Model): email = models.EmailField() content = models.CharField(max_length=200) created = models.DateTimeField(auto_now_add=True) class Meta: app_label = 'tests_app'drf-extensions-0.3.1/tests_app/tests/functional/mixins/detail_serializer_mixin/serializers.py000066400000000000000000000007001277312624600331140ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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.3.1/tests_app/tests/functional/mixins/detail_serializer_mixin/tests.py000066400000000000000000000075131277312624600317330ustar00rootroot00000000000000# -*- coding: utf-8 -*- import datetime from django.test import TestCase, override_settings from rest_framework_extensions.test import APIRequestFactory # todo: use from rest_framework when released 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.3.1/tests_app/tests/functional/mixins/detail_serializer_mixin/urls.py000066400000000000000000000013431277312624600315510ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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.3.1/tests_app/tests/functional/mixins/detail_serializer_mixin/views.py000066400000000000000000000031671277312624600317270ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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(CommentWithDetailSerializerAndNoArgsForGetQuerySetViewSet, self).get_queryset()drf-extensions-0.3.1/tests_app/tests/functional/mixins/list_destroy_model_mixin/000077500000000000000000000000001277312624600304225ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/mixins/list_destroy_model_mixin/__init__.py000066400000000000000000000000011277312624600325220ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/functional/mixins/list_destroy_model_mixin/models.py000066400000000000000000000002741277312624600322620ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.db import models class CommentForListDestroyModelMixin(models.Model): email = models.EmailField() class Meta: app_label = 'tests_app'drf-extensions-0.3.1/tests_app/tests/functional/mixins/list_destroy_model_mixin/tests.py000066400000000000000000000064231277312624600321430ustar00rootroot00000000000000# -*- coding: utf-8 -*- 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 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.3.1/tests_app/tests/functional/mixins/list_destroy_model_mixin/urls.py000066400000000000000000000005221277312624600317600ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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.3.1/tests_app/tests/functional/mixins/list_destroy_model_mixin/views.py000066400000000000000000000016161277312624600321350ustar00rootroot00000000000000# -*- coding: utf-8 -*- import 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 class CommentViewSet(ListDestroyModelMixin, viewsets.ModelViewSet): queryset = Comment.objects.all() serializer_class = CommentSerializer filter_backends = (filters.DjangoFilterBackend,) filter_class = CommentFilter class CommentViewSetWithPermissions(CommentViewSet): permission_classes = (DjangoModelPermissions,) drf-extensions-0.3.1/tests_app/tests/functional/mixins/list_update_model_mixin/000077500000000000000000000000001277312624600302135ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/mixins/list_update_model_mixin/__init__.py000066400000000000000000000000011277312624600323130ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/functional/mixins/list_update_model_mixin/models.py000066400000000000000000000007521277312624600320540ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.db import models class CommentForListUpdateModelMixin(models.Model): email = models.EmailField() class Meta: app_label = 'tests_app' 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) class Meta: app_label = 'tests_app'drf-extensions-0.3.1/tests_app/tests/functional/mixins/list_update_model_mixin/serializers.py000066400000000000000000000012051277312624600331170ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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 drf-extensions-0.3.1/tests_app/tests/functional/mixins/list_update_model_mixin/tests.py000066400000000000000000000174231277312624600317360ustar00rootroot00000000000000# -*- coding: utf-8 -*- import json import unittest 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 rest_framework_extensions.utils import get_rest_framework_features 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' } ] if not get_rest_framework_features()['write_only_fields']: expected[0]['password'] = self.user.password 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) expected_message = { 'detail': "invalid literal for int() with base 10: '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']) @unittest.skipIf( not get_rest_framework_features()['write_only_fields'], "Current DRF version doesn't support write_only_fields" ) 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.3.1/tests_app/tests/functional/mixins/list_update_model_mixin/urls.py000066400000000000000000000006151277312624600315540ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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.3.1/tests_app/tests/functional/mixins/list_update_model_mixin/views.py000066400000000000000000000020031277312624600317150ustar00rootroot00000000000000# -*- coding: utf-8 -*- import 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 = (filters.DjangoFilterBackend,) filter_class = CommentFilter class CommentViewSetWithPermissions(CommentViewSet): permission_classes = (DjangoModelPermissions,) class UserViewSet(ListUpdateModelMixin, viewsets.ModelViewSet): queryset = User.objects.all() serializer_class = UserSerializerdrf-extensions-0.3.1/tests_app/tests/functional/mixins/paginate_by_max_mixin/000077500000000000000000000000001277312624600276455ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/mixins/paginate_by_max_mixin/__init__.py000066400000000000000000000000011277312624600317450ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/functional/mixins/paginate_by_max_mixin/models.py000066400000000000000000000004361277312624600315050ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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: app_label = 'tests_app'drf-extensions-0.3.1/tests_app/tests/functional/mixins/paginate_by_max_mixin/pagination.py000066400000000000000000000006751277312624600323600ustar00rootroot00000000000000from 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.3.1/tests_app/tests/functional/mixins/paginate_by_max_mixin/serializers.py000066400000000000000000000004521277312624600325540ustar00rootroot00000000000000# -*- coding: utf-8 -*- from rest_framework import serializers from .models import CommentForPaginateByMaxMixin class CommentSerializer(serializers.ModelSerializer): class Meta: model = CommentForPaginateByMaxMixin fields = ( 'id', 'email', ) drf-extensions-0.3.1/tests_app/tests/functional/mixins/paginate_by_max_mixin/tests.py000066400000000000000000000053451277312624600313700ustar00rootroot00000000000000# -*- coding: utf-8 -*- import 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.3.1/tests_app/tests/functional/mixins/paginate_by_max_mixin/urls.py000066400000000000000000000010441277312624600312030ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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.3.1/tests_app/tests/functional/mixins/paginate_by_max_mixin/views.py000066400000000000000000000015431277312624600313570ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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() 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.3.1/tests_app/tests/functional/permissions/000077500000000000000000000000001277312624600243565ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/permissions/__init__.py000066400000000000000000000000011277312624600264560ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/functional/permissions/extended_django_object_permissions/000077500000000000000000000000001277312624600334615ustar00rootroot00000000000000__init__.py000066400000000000000000000000011277312624600355020ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/permissions/extended_django_object_permissions models.py000066400000000000000000000005171277312624600352420ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/permissions/extended_django_object_permissions# -*- coding: utf-8 -*- from django.db import models class PermissionsComment(models.Model): text = models.CharField(max_length=100) class Meta: permissions = ( ('view_permissionscomment', 'Can view comment'), # add, change, delete built in to django ) app_label = 'tests_app'tests.py000066400000000000000000000225251277312624600351240ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/permissions/extended_django_object_permissions# -*- coding: utf-8 -*- import 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 rest_framework_extensions.compat import get_model_name from tests_app.testutils import basic_auth_header from .models import PermissionsComment class ExtendedDjangoObjectPermissionTestMixin(object): 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', name='Can view comment', content_type=ContentType.objects.get_for_model(PermissionsComment) ) # give everyone model level permissions, as we are not testing those everyone = Group.objects.create(name='everyone') model_name = get_model_name(PermissionsComment) 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.py000066400000000000000000000014171277312624600347440ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/permissions/extended_django_object_permissions# -*- coding: utf-8 -*- from 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.py000066400000000000000000000036151277312624600351160ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/permissions/extended_django_object_permissions# -*- coding: utf-8 -*- from rest_framework import viewsets, serializers from rest_framework import authentication try: from rest_framework.filters import DjangoObjectPermissionsFilter except ImportError: class DjangoObjectPermissionsFilter(object): pass try: from rest_framework_extensions.permissions import ExtendedDjangoObjectPermissions except ImportError: class ExtendedDjangoObjectPermissions(object): 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 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.3.1/tests_app/tests/functional/routers/000077500000000000000000000000001277312624600235065ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/routers/__init__.py000066400000000000000000000000011277312624600256060ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/functional/routers/extended_default_router/000077500000000000000000000000001277312624600304125ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/routers/extended_default_router/__init__.py000066400000000000000000000000011277312624600325120ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/functional/routers/extended_default_router/models.py000066400000000000000000000011751277312624600322530ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.db import models class DefaultRouterUserModel(models.Model): name = models.CharField(max_length=10) groups = models.ManyToManyField('DefaultRouterGroupModel', related_name='user_groups') class Meta: app_label = 'tests_app' class DefaultRouterGroupModel(models.Model): name = models.CharField(max_length=10) permissions = models.ManyToManyField('DefaultRouterPermissionModel') class Meta: app_label = 'tests_app' class DefaultRouterPermissionModel(models.Model): name = models.CharField(max_length=10) class Meta: app_label = 'tests_app'drf-extensions-0.3.1/tests_app/tests/functional/routers/extended_default_router/tests.py000066400000000000000000000020161277312624600321250ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.core.urlresolvers import NoReverseMatch from django.test import override_settings 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.3.1/tests_app/tests/functional/routers/extended_default_router/urls.py000066400000000000000000000012231277312624600317470ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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.3.1/tests_app/tests/functional/routers/extended_default_router/views.py000066400000000000000000000011151277312624600321170ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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.3.1/tests_app/tests/functional/routers/models.py000066400000000000000000000002621277312624600253430ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.db import models class RouterTestModel(models.Model): uuid = models.CharField(max_length=20) text = models.CharField(max_length=200)drf-extensions-0.3.1/tests_app/tests/functional/routers/nested_router_mixin/000077500000000000000000000000001277312624600275745ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/routers/nested_router_mixin/__init__.py000066400000000000000000000000011277312624600316740ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/functional/routers/nested_router_mixin/models.py000066400000000000000000000026571277312624600314430ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.db import models from django.contrib.contenttypes.models import ContentType 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 Meta: app_label = 'tests_app' class NestedRouterMixinGroupModel(models.Model): name = models.CharField(max_length=10) permissions = models.ManyToManyField('NestedRouterMixinPermissionModel') class Meta: app_label = 'tests_app' class NestedRouterMixinPermissionModel(models.Model): name = models.CharField(max_length=10) class Meta: app_label = 'tests_app' class NestedRouterMixinTaskModel(models.Model): title = models.CharField(max_length=30) class Meta: app_label = 'tests_app' class NestedRouterMixinBookModel(models.Model): title = models.CharField(max_length=30) class Meta: app_label = 'tests_app' class NestedRouterMixinCommentModel(models.Model): content_type = models.ForeignKey(ContentType, blank=True, null=True) object_id = models.PositiveIntegerField(blank=True, null=True) content_object = GenericForeignKey() text = models.CharField(max_length=30) class Meta: app_label = 'tests_app'drf-extensions-0.3.1/tests_app/tests/functional/routers/nested_router_mixin/serializers.py000066400000000000000000000026121277312624600325030ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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.3.1/tests_app/tests/functional/routers/nested_router_mixin/tests.py000066400000000000000000000446141277312624600313210ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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 = [ self.permissions['read'] ] self.groups['admins'].permissions = [ self.permissions['read'], self.permissions['update'], ] self.groups['super_admins'].permissions = [ self.permissions['read'], self.permissions['update'], self.permissions['delete'], ] # add groups to users self.users['vova'].groups = [ self.groups['users'] ] self.users['gena'].groups = [ 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 = [ self.groups['users'] ] self.users['gena'].groups = [ 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.3.1/tests_app/tests/functional/routers/nested_router_mixin/urls.py000066400000000000000000000017301277312624600311340ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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.py000066400000000000000000000011211277312624600346230ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/routers/nested_router_mixin# -*- coding: utf-8 -*- from 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.py000066400000000000000000000006301277312624600355630ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/functional/routers/nested_router_mixin# -*- coding: utf-8 -*- from 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.3.1/tests_app/tests/functional/routers/nested_router_mixin/views.py000066400000000000000000000062751277312624600313150ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.contrib.contenttypes.models import ContentType from rest_framework.decorators import detail_route, list_route 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 @list_route(methods=['post'], url_path='users-list-action') def users_list_action(self, request, *args, **kwargs): return Response('users list action') @detail_route(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 @list_route(url_path='groups-list-link') def groups_list_link(self, request, *args, **kwargs): return Response('groups list link') @detail_route(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 @list_route(methods=['post'], url_path='permissions-list-action') def permissions_list_action(self, request, *args, **kwargs): return Response('permissions list action') @detail_route(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(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) ) class UserViewSetWithEmailLookup(NestedViewSetMixin, ModelViewSet): queryset = UserModel.objects.all() serializer_class = UserSerializer lookup_field = 'email' lookup_value_regex = '[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+' drf-extensions-0.3.1/tests_app/tests/functional/routers/tests.py000066400000000000000000000036031277312624600252240ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.test import TestCase from rest_framework_extensions.compat_drf import get_lookup_allowed_symbols from rest_framework_extensions.utils import get_rest_framework_features 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 lookup_allowed_symbols = get_lookup_allowed_symbols() for exp in ['^router-viewset/$', '^router-viewset/{0}/$'.format(lookup_allowed_symbols), '^router-viewset/list_controller/$', '^router-viewset/{0}/detail_controller/$'.format(lookup_allowed_symbols)]: 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 lookup_allowed_symbols = get_lookup_allowed_symbols( force_dot=get_rest_framework_features()['allow_dot_in_lookup_regex_without_trailing_slash'] ) for exp in ['^router-viewset$', '^router-viewset/{0}$'.format(lookup_allowed_symbols), '^router-viewset/list_controller$', '^router-viewset/{0}/detail_controller$'.format(lookup_allowed_symbols)]: msg = 'Should find url pattern with regexp %s' % exp self.assertIsNotNone(get_url_pattern_by_regex_pattern(urls, exp), msg=msg) drf-extensions-0.3.1/tests_app/tests/functional/routers/views.py000066400000000000000000000005751277312624600252240ustar00rootroot00000000000000# -*- coding: utf-8 -*- from rest_framework import viewsets from rest_framework.decorators import detail_route, list_route from .models import RouterTestModel class RouterViewSet(viewsets.ModelViewSet): queryset = RouterTestModel.objects.all() @detail_route() def detail_controller(self): pass @list_route() def list_controller(self): pass drf-extensions-0.3.1/tests_app/tests/unit/000077500000000000000000000000001277312624600206205ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/__init__.py000066400000000000000000000000001277312624600227170ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/cache/000077500000000000000000000000001277312624600216635ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/cache/__init__.py000066400000000000000000000000001277312624600237620ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/cache/decorators/000077500000000000000000000000001277312624600240305ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/cache/decorators/__init__.py000066400000000000000000000000011277312624600261300ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/unit/cache/decorators/tests.py000066400000000000000000000241371277312624600255530ustar00rootroot00000000000000# -*- coding: utf-8 -*- from mock import Mock, patch from django.core.cache import caches from django.test import TestCase from rest_framework import views from rest_framework.response import Response from rest_framework_extensions.test import APIRequestFactory from rest_framework_extensions.cache.decorators import cache_response from rest_framework_extensions.settings import extensions_api_settings from tests_app.testutils import override_extensions_api_settings factory = APIRequestFactory() class CacheResponseTest(TestCase): def setUp(self): super(CacheResponseTest, self).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').content, 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').content, 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').content, 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_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(u'Cached response from method 4') view_instance.finalize_response(request=self.request, response=cached_response) cached_response.render() self.cache.set('cache_response_key', cached_response) response = view_instance.dispatch(request=self.request) self.assertEqual( response.content.decode('utf-8'), u'"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.assertTrue(hasattr(data_from_cache, 'content')) self.assertEqual( data_from_cache.content.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.assertTrue(hasattr(data_from_cache, 'content')) self.assertEqual(data_from_cache.content.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(TestView, self).__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(TestView, self).__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) drf-extensions-0.3.1/tests_app/tests/unit/etag/000077500000000000000000000000001277312624600215405ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/etag/__init__.py000066400000000000000000000000001277312624600236370ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/etag/decorators/000077500000000000000000000000001277312624600237055ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/etag/decorators/__init__.py000066400000000000000000000000011277312624600260050ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/unit/etag/decorators/tests.py000066400000000000000000000274251277312624600254330ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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.etag.decorators import 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 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(object): 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(ETAGProcessorTestBehavior_if_none_match, self).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(ETAGProcessorTestBehavior_if_match, self).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 ) drf-extensions-0.3.1/tests_app/tests/unit/key_constructor/000077500000000000000000000000001277312624600240555ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/key_constructor/__init__.py000066400000000000000000000000011277312624600261550ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/unit/key_constructor/bits/000077500000000000000000000000001277312624600250165ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/key_constructor/bits/__init__.py000066400000000000000000000000011277312624600271160ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/unit/key_constructor/bits/models.py000066400000000000000000000002751277312624600266570ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.db import models class BitTestModel(models.Model): is_active = models.BooleanField(default=False) class Meta: app_label = 'tests_app' drf-extensions-0.3.1/tests_app/tests/unit/key_constructor/bits/tests.py000066400000000000000000000431171277312624600265400ustar00rootroot00000000000000# -*- coding: utf-8 -*- from mock import Mock 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.utils import get_django_features from rest_framework_extensions.key_constructor.bits import ( KeyBitDictBase, UniqueMethodIdKeyBit, LanguageKeyBit, FormatKeyBit, UserKeyBit, HeadersKeyBit, RequestMetaKeyBit, QueryParamsKeyBit, UniqueViewIdKeyBit, PaginationKeyBit, ListSqlQueryKeyBit, RetrieveSqlQueryKeyBit, 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.user.is_authenticated = Mock(return_value=False) 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.kwargs['request'].user.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'), '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': u'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': u'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': u'10', 'page': u'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), {}) 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 "tests_app_bittestmodel"."id", "tests_app_bittestmodel"."is_active" ' u'FROM "tests_app_bittestmodel" ' u'WHERE "tests_app_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 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 "tests_app_bittestmodel"."id", "tests_app_bittestmodel"."is_active" ' u'FROM "tests_app_bittestmodel" ' u'WHERE ("tests_app_bittestmodel"."is_active" = True {space}AND "tests_app_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 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.3.1/tests_app/tests/unit/key_constructor/constructor/000077500000000000000000000000001277312624600264425ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/key_constructor/constructor/__init__.py000066400000000000000000000000011277312624600305420ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/unit/key_constructor/constructor/tests.py000066400000000000000000000255131277312624600301640ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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.3.1/tests_app/tests/unit/routers/000077500000000000000000000000001277312624600223235ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/routers/__init__.py000066400000000000000000000000001277312624600244220ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/routers/nested_router_mixin/000077500000000000000000000000001277312624600264115ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/routers/nested_router_mixin/__init__.py000066400000000000000000000000011277312624600305110ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/unit/routers/nested_router_mixin/models.py000066400000000000000000000014111277312624600302430ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.db import models class NestedRouterMixinPermissionModel(models.Model): name = models.CharField(max_length=10) class Meta: app_label = 'permission_app' verbose_name = 'permission' class NestedRouterMixinGroupModel(models.Model): name = models.CharField(max_length=10) permissions = models.ManyToManyField( 'NestedRouterMixinPermissionModel') class Meta: app_label = 'group_app' verbose_name = 'group' class NestedRouterMixinUserModel(models.Model): name = models.CharField(max_length=10) groups = models.ManyToManyField( 'NestedRouterMixinGroupModel', related_name='user_groups') class Meta: app_label = 'user_app' verbose_name = 'user' drf-extensions-0.3.1/tests_app/tests/unit/routers/nested_router_mixin/tests.py000066400000000000000000000107331277312624600301310ustar00rootroot00000000000000# -*- coding: utf-8 -*- from rest_framework_extensions.compat_drf import get_lookup_allowed_symbols 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, ) class NestedRouterMixinTest(APITestCase): def get_lookup_regex(self, value): return get_lookup_allowed_symbols(value) # return '(?P<{0}>[^/]+)'.format(value) def get_parent_lookup_regex(self, value): return get_lookup_allowed_symbols(compose_parent_pk_kwarg_name(value), force_dot=True) # return '(?P<{0}>[^/.]+)'.format(compose_parent_pk_kwarg_name(value)) 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(router.urls[0]._regex, r'^users/$') # test user detail self.assertEqual(router.urls[1].name, 'user-detail') self.assertEqual(router.urls[1]._regex, 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(router.urls[0]._regex, r'^users/$') # test user detail self.assertEqual(router.urls[1].name, 'user-detail') self.assertEqual(router.urls[1]._regex, r'^users/{0}/$'.format(self.get_lookup_regex('pk'))) # test users group list self.assertEqual(router.urls[2].name, 'users-group-list') self.assertEqual(router.urls[2]._regex, 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(router.urls[3]._regex, 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(router.urls[0]._regex, r'^users/$') # test user detail self.assertEqual(router.urls[1].name, 'user-detail') self.assertEqual(router.urls[1]._regex, r'^users/{0}/$'.format(self.get_lookup_regex('pk'))) # test users group list self.assertEqual(router.urls[2].name, 'users-group-list') self.assertEqual(router.urls[2]._regex, 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(router.urls[3]._regex, 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(router.urls[4]._regex, 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(router.urls[5]._regex, 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') ), )drf-extensions-0.3.1/tests_app/tests/unit/routers/nested_router_mixin/views.py000066400000000000000000000006601277312624600301220ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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 drf-extensions-0.3.1/tests_app/tests/unit/routers/tests.py000066400000000000000000000172301277312624600240420ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.test import TestCase from rest_framework import viewsets from rest_framework.decorators import detail_route, list_route from rest_framework.response import Response from rest_framework_extensions.routers import ExtendedDefaultRouter from rest_framework_extensions.compat_drf import add_trailing_slash_if_needed 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_routes_should_be_first_in_order(self): class BasicViewSet(viewsets.ViewSet): def list(self, request, *args, **kwargs): return Response({'method': 'list'}) @detail_route() def detail1(self, request, *args, **kwargs): return Response({'method': 'detail1'}) routes = self.router.get_routes(BasicViewSet) expected = [ '{basename}-detail1', '{basename}-list', '{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): @detail_route() 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, add_trailing_slash_if_needed(u'^{prefix}/{lookup}/action1/$'), msg) def test_detail_route__with_methods(self): class BasicViewSet(viewsets.ViewSet): @detail_route(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, add_trailing_slash_if_needed(u'^{prefix}/{lookup}/action1/$'), msg) def test_detail_route__with_methods__and__with_url_path(self): class BasicViewSet(viewsets.ViewSet): @detail_route(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, add_trailing_slash_if_needed(u'^{prefix}/{lookup}/action-one/$'), msg) def test_list_route(self): class BasicViewSet(viewsets.ViewSet): @list_route() 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, add_trailing_slash_if_needed(u'^{prefix}/action1/$'), msg) def test_list_route__with_methods(self): class BasicViewSet(viewsets.ViewSet): @list_route(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, add_trailing_slash_if_needed(u'^{prefix}/action1/$'), msg) def test_list_route__with_methods__and__with_url_path(self): class BasicViewSet(viewsets.ViewSet): @list_route(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, add_trailing_slash_if_needed(u'^{prefix}/action-one/$'), msg) def test_list_route_and_detail_route_with_exact_names(self): class BasicViewSet(viewsets.ViewSet): @list_route(url_path='action-one') def action1(self, request, *args, **kwargs): pass @detail_route(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, add_trailing_slash_if_needed(u'^{prefix}/action-one/$')) self.assertEqual(action1_detail_route.mapping, {'get': 'action1_detail'}) self.assertEqual(action1_detail_route.url, add_trailing_slash_if_needed(u'^{prefix}/{lookup}/action-one/$')) def test_list_route_and_detail_route_names(self): class BasicViewSet(viewsets.ViewSet): @list_route() def action1(self, request, *args, **kwargs): pass @detail_route() 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-list') self.assertEqual(action2_detail_route.name, u'{basename}-action2') def test_list_route_and_detail_route_names__with_endpoints(self): class BasicViewSet(viewsets.ViewSet): @list_route(url_path='action_one') def action1(self, request, *args, **kwargs): pass @detail_route(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}-action-one-list') self.assertEqual(action2_detail_route.name, u'{basename}-action-two') drf-extensions-0.3.1/tests_app/tests/unit/serializers/000077500000000000000000000000001277312624600231545ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/serializers/__init__.py000066400000000000000000000000011277312624600252540ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/unit/serializers/models.py000066400000000000000000000014221277312624600250100ustar00rootroot00000000000000# -*- coding: utf-8 -*- import os from django.db import models from django.conf import settings class UserModel(models.Model): name = models.CharField(max_length=20) class Meta: app_label = 'tests_app' upload_to = os.path.join(settings.FILE_STORAGE_DIR, 'test_serializers') class CommentModel(models.Model): user = models.ForeignKey(UserModel, related_name='comments') users_liked = models.ManyToManyField(UserModel, blank=True, null=True) title = models.CharField(max_length=20) text = models.CharField(max_length=200) attachment = models.FileField( upload_to=upload_to, blank=True, null=True, max_length=500) hidden_text = models.CharField(max_length=200, blank=True, null=True) class Meta: app_label = 'tests_app' drf-extensions-0.3.1/tests_app/tests/unit/serializers/serializers.py000066400000000000000000000025361277312624600260700ustar00rootroot00000000000000# -*- coding: utf-8 -*- from 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.3.1/tests_app/tests/unit/serializers/tests.py000066400000000000000000000233731277312624600247000ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.test import TestCase from django.core.files import File from rest_framework_extensions.utils import get_rest_framework_features from rest_framework_extensions.compat import BytesIO from .serializers import CommentSerializer, UserSerializer, \ CommentSerializerWithExpandedUsersLiked, CommentSerializerWithAllowedUserId from .models import UserModel, CommentModel class PartialUpdateSerializerMixinTest(TestCase): def setUp(self): self.files = [ File(BytesIO(u'file one'.encode('utf-8')), name='file1.txt'), File(BytesIO(u'file two'.encode('utf-8')), name='file2.txt'), ] self.files[0]._set_size(8) self.files[1]._set_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 } if get_rest_framework_features()['uses_single_request_data_in_serializers']: serializer_three_kwargs['data'] = {'attachment': self.files[1]} else: serializer_three_kwargs.update({ 'data': {}, 'files': {'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')) 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.3.1/tests_app/tests/unit/utils/000077500000000000000000000000001277312624600217605ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests/unit/utils/__init__.py000066400000000000000000000000011277312624600240600ustar00rootroot00000000000000 drf-extensions-0.3.1/tests_app/tests/unit/utils/tests.py000066400000000000000000000012741277312624600235000ustar00rootroot00000000000000# -*- coding: utf-8 -*- from django.test import TestCase from rest_framework_extensions.utils import prepare_header_name 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') drf-extensions-0.3.1/tests_app/tests_file_storage/000077500000000000000000000000001277312624600223645ustar00rootroot00000000000000drf-extensions-0.3.1/tests_app/tests_file_storage/.gitignore000066400000000000000000000000011277312624600243430ustar00rootroot00000000000000*drf-extensions-0.3.1/tests_app/testutils.py000066400000000000000000000024201277312624600211070ustar00rootroot00000000000000# -*- coding: utf-8 -*- import base64 from mock import patch from rest_framework import HTTP_HEADER_ENCODING 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 pattern.regex.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.3.1/tox.ini000066400000000000000000000014101277312624600160040ustar00rootroot00000000000000[tox] envlist = django.1.10 django.1.9 django.1.8.lts, [testenv] deps= -rtests_app/requirements.txt setenv = PYTHONPATH = {toxinidir}:{toxinidir}/tests_app commands= {envbindir}/django-admin.py test --settings=settings {posargs} [testenv:drf.3.4.0] deps= {[testenv]deps} Django>=1.10,<1.11 djangorestframework>=3.4.0 django-guardian==1.4.4 [testenv:django.1.10] deps= {[testenv]deps} Django>=1.10a1,<1.11 djangorestframework>=3.3.3 django-guardian==1.4.4 [testenv:django.1.9] deps= {[testenv]deps} Django>=1.9,<1.10 djangorestframework>=3.3.3 django-guardian==1.4.4 [testenv:django.1.8.lts] deps= {[testenv]deps} Django>=1.8,<1.9 djangorestframework>=3.3.2 django-guardian==1.4.4