Merge pull request #290 from taigaio/us/1475/internationalization
i18nremotes/origin/enhancement/email-actions
commit
ace8e373c7
|
@ -0,0 +1,16 @@
|
||||||
|
[main]
|
||||||
|
host = https://www.transifex.com
|
||||||
|
lang_map = sr@latin:sr_Latn, zh_CN:zh_Hans, zh_TW:zh_Hant
|
||||||
|
|
||||||
|
|
||||||
|
[taiga-back.main]
|
||||||
|
file_filter = locale/<lang>/LC_MESSAGES/django.po
|
||||||
|
source_file = locale/en/LC_MESSAGES/django.po
|
||||||
|
source_lang = en
|
||||||
|
type = PO
|
||||||
|
|
||||||
|
[taiga-back.taiga]
|
||||||
|
file_filter = taiga/locale/<lang>/LC_MESSAGES/django.po
|
||||||
|
source_file = taiga/locale/en/LC_MESSAGES/django.po
|
||||||
|
source_lang = en
|
||||||
|
type = PO
|
11
CHANGELOG.md
11
CHANGELOG.md
|
@ -1,8 +1,17 @@
|
||||||
# Changelog #
|
# Changelog #
|
||||||
|
|
||||||
|
|
||||||
## 1.6.0 Abies Bifolia (2015-03-17)
|
## 1.7.0 ??? (unreleased)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- Make Taiga translatable (i18n support).
|
||||||
|
|
||||||
|
### Misc
|
||||||
|
- Lots of small and not so small bugfixes.
|
||||||
|
- Remove djangorestframework from requirements. Move useful code to core.
|
||||||
|
|
||||||
|
|
||||||
|
## 1.6.0 Abies Bifolia (2015-03-17)
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
- Added custom fields per project for user stories, tasks and issues.
|
- Added custom fields per project for user stories, tasks and issues.
|
||||||
|
|
|
@ -9,3 +9,5 @@ pytest-pythonpath==0.6
|
||||||
coverage==3.7.1
|
coverage==3.7.1
|
||||||
coveralls==0.4.2
|
coveralls==0.4.2
|
||||||
django-slowdown==0.0.1
|
django-slowdown==0.0.1
|
||||||
|
|
||||||
|
transifex-client==0.11.1b0
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
djangorestframework==2.3.13
|
|
||||||
Django==1.7.6
|
Django==1.7.6
|
||||||
|
#djangorestframework==2.3.13 # It's not necessary since Taiga 1.7
|
||||||
django-picklefield==0.3.1
|
django-picklefield==0.3.1
|
||||||
django-sampledatahelper==0.2.2
|
django-sampledatahelper==0.2.2
|
||||||
gunicorn==19.1.1
|
gunicorn==19.1.1
|
||||||
|
|
|
@ -0,0 +1,242 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
#
|
||||||
|
# NOTE: This script is based on django's manage_translations.py script
|
||||||
|
# (https://github.com/django/django/blob/master/scripts/manage_translations.py)
|
||||||
|
#
|
||||||
|
# This python file contains utility scripts to manage taiga translations.
|
||||||
|
# It has to be run inside the taiga-back git root directory.
|
||||||
|
#
|
||||||
|
# The following commands are available:
|
||||||
|
#
|
||||||
|
# * update_catalogs: check for new strings in taiga-back catalogs, and
|
||||||
|
# output how much strings are new/changed.
|
||||||
|
#
|
||||||
|
# * lang_stats: output statistics for each catalog/language combination
|
||||||
|
#
|
||||||
|
# * fetch: fetch translations from transifex.com
|
||||||
|
#
|
||||||
|
# Each command support the --languages and --resources options to limit their
|
||||||
|
# operation to the specified language or resource. For example, to get stats
|
||||||
|
# for Spanish in contrib.admin, run:
|
||||||
|
#
|
||||||
|
# $ python scripts/manage_translations.py lang_stats --language=es --resources=taiga
|
||||||
|
|
||||||
|
|
||||||
|
import os
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from argparse import RawTextHelpFormatter
|
||||||
|
|
||||||
|
from subprocess import PIPE, Popen, call
|
||||||
|
|
||||||
|
from django_jinja.management.commands import makemessages
|
||||||
|
|
||||||
|
|
||||||
|
def _get_locale_dirs(resources):
|
||||||
|
"""
|
||||||
|
Return a tuple (app name, absolute path) for all locale directories.
|
||||||
|
If resources list is not None, filter directories matching resources content.
|
||||||
|
"""
|
||||||
|
contrib_dir = os.getcwd()
|
||||||
|
dirs = []
|
||||||
|
|
||||||
|
# Collect all locale directories
|
||||||
|
for contrib_name in os.listdir(contrib_dir):
|
||||||
|
path = os.path.join(contrib_dir, contrib_name, "locale")
|
||||||
|
if os.path.isdir(path):
|
||||||
|
dirs.append((contrib_name, path))
|
||||||
|
|
||||||
|
# Filter by resources, if any
|
||||||
|
if resources is not None:
|
||||||
|
res_names = [d[0] for d in dirs]
|
||||||
|
dirs = [ld for ld in dirs if ld[0] in resources]
|
||||||
|
if len(resources) > len(dirs):
|
||||||
|
print("You have specified some unknown resources. "
|
||||||
|
"Available resource names are: {0}".format(", ".join(res_names)))
|
||||||
|
exit(1)
|
||||||
|
return dirs
|
||||||
|
|
||||||
|
|
||||||
|
def _tx_resource_for_name(name):
|
||||||
|
""" Return the Transifex resource name """
|
||||||
|
return "taiga-back.{}".format(name)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_diff(cat_name, base_path):
|
||||||
|
"""
|
||||||
|
Output the approximate number of changed/added strings in the en catalog.
|
||||||
|
"""
|
||||||
|
po_path = "{path}/en/LC_MESSAGES/django.po".format(path=base_path)
|
||||||
|
p = Popen("git diff -U0 {0} | egrep '^[-+]msgid' | wc -l".format(po_path),
|
||||||
|
stdout=PIPE, stderr=PIPE, shell=True)
|
||||||
|
output, errors = p.communicate()
|
||||||
|
num_changes = int(output.strip())
|
||||||
|
print("{0} changed/added messages in '{1}' catalog.".format(num_changes, cat_name))
|
||||||
|
|
||||||
|
|
||||||
|
def update_catalogs(resources=None, languages=None):
|
||||||
|
"""
|
||||||
|
Update the en/LC_MESSAGES/django.po (all) files with
|
||||||
|
new/updated translatable strings.
|
||||||
|
"""
|
||||||
|
cmd = makemessages.Command()
|
||||||
|
opts = {
|
||||||
|
"locale": ["en"],
|
||||||
|
"extensions": ["py", "jinja"],
|
||||||
|
|
||||||
|
# Default values
|
||||||
|
"domain": "django",
|
||||||
|
"all": False,
|
||||||
|
"symlinks": False,
|
||||||
|
"ignore_patterns": [],
|
||||||
|
"use_default_ignore_patterns": True,
|
||||||
|
"no_wrap": False,
|
||||||
|
"no_location": False,
|
||||||
|
"no_obsolete": False,
|
||||||
|
"keep_pot": False,
|
||||||
|
"verbosity": "0",
|
||||||
|
}
|
||||||
|
|
||||||
|
if resources is not None:
|
||||||
|
print("`update_catalogs` will always process all resources.")
|
||||||
|
|
||||||
|
os.chdir(os.getcwd())
|
||||||
|
print("Updating en catalogs for all taiga-back resourcess...")
|
||||||
|
cmd.handle(**opts)
|
||||||
|
|
||||||
|
# Output changed stats
|
||||||
|
contrib_dirs = _get_locale_dirs(None)
|
||||||
|
for name, dir_ in contrib_dirs:
|
||||||
|
_check_diff(name, dir_)
|
||||||
|
|
||||||
|
|
||||||
|
def lang_stats(resources=None, languages=None):
|
||||||
|
"""
|
||||||
|
Output language statistics of committed translation files for each catalog.
|
||||||
|
If resources is provided, it should be a list of translation resource to
|
||||||
|
limit the output (e.g. ['main', 'taiga']).
|
||||||
|
"""
|
||||||
|
locale_dirs = _get_locale_dirs(resources)
|
||||||
|
|
||||||
|
for name, dir_ in locale_dirs:
|
||||||
|
print("\nShowing translations stats for '{res}':".format(res=name))
|
||||||
|
langs = sorted([d for d in os.listdir(dir_) if not d.startswith('_')])
|
||||||
|
|
||||||
|
for lang in langs:
|
||||||
|
if languages and lang not in languages:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# TODO: merge first with the latest en catalog
|
||||||
|
p = Popen("msgfmt -vc -o /dev/null {path}/{lang}/LC_MESSAGES/django.po".format(path=dir_, lang=lang),
|
||||||
|
stdout=PIPE, stderr=PIPE, shell=True)
|
||||||
|
output, errors = p.communicate()
|
||||||
|
|
||||||
|
if p.returncode == 0:
|
||||||
|
# msgfmt output stats on stderr
|
||||||
|
print("{0}: {1}".format(lang, errors.strip().decode("utf-8")))
|
||||||
|
else:
|
||||||
|
print("Errors happened when checking {0} translation for {1}:\n{2}".format(lang, name, errors))
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(resources=None, languages=None):
|
||||||
|
"""
|
||||||
|
Fetch translations from Transifex, wrap long lines, generate mo files.
|
||||||
|
"""
|
||||||
|
locale_dirs = _get_locale_dirs(resources)
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for name, dir_ in locale_dirs:
|
||||||
|
# Transifex pull
|
||||||
|
if languages is None:
|
||||||
|
call("tx pull -r {res} -a -f --minimum-perc=5".format(res=_tx_resource_for_name(name)), shell=True)
|
||||||
|
languages = sorted([d for d in os.listdir(dir_) if not d.startswith("_") and d != "en"])
|
||||||
|
else:
|
||||||
|
for lang in languages:
|
||||||
|
call("tx pull -r {res} -f -l {lang}".format(res=_tx_resource_for_name(name), lang=lang), shell=True)
|
||||||
|
|
||||||
|
# msgcat to wrap lines and msgfmt for compilation of .mo file
|
||||||
|
for lang in languages:
|
||||||
|
po_path = "{path}/{lang}/LC_MESSAGES/django.po".format(path=dir_, lang=lang)
|
||||||
|
|
||||||
|
if not os.path.exists(po_path):
|
||||||
|
print("No {lang} translation for resource {res}".format(lang=lang, res=name))
|
||||||
|
continue
|
||||||
|
|
||||||
|
call("msgcat -o {0} {0}".format(po_path), shell=True)
|
||||||
|
res = call("msgfmt -c -o {0}.mo {1}".format(po_path[:-3], po_path), shell=True)
|
||||||
|
|
||||||
|
if res != 0:
|
||||||
|
errors.append((name, lang))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
print("\nWARNING: Errors have occurred in following cases:")
|
||||||
|
for resource, lang in errors:
|
||||||
|
print("\tResource {res} for language {lang}".format(res=resource, lang=lang))
|
||||||
|
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def commit(resources=None, languages=None):
|
||||||
|
"""
|
||||||
|
Commit messages to Transifex,
|
||||||
|
"""
|
||||||
|
locale_dirs = _get_locale_dirs(resources)
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
for name, dir_ in locale_dirs:
|
||||||
|
# Transifex push
|
||||||
|
if languages is None:
|
||||||
|
call("tx push -r {res} -s -l en".format(res=_tx_resource_for_name(name)), shell=True)
|
||||||
|
else:
|
||||||
|
for lang in languages:
|
||||||
|
call("tx push -r {res} -l {lang}".format(res= _tx_resource_for_name(name), lang=lang), shell=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
try:
|
||||||
|
devnull = open(os.devnull)
|
||||||
|
Popen(["tx"], stdout=devnull, stderr=devnull).communicate()
|
||||||
|
except OSError as e:
|
||||||
|
if e.errno == os.errno.ENOENT:
|
||||||
|
print("""
|
||||||
|
You need transifex-client, install it.
|
||||||
|
|
||||||
|
1. Install transifex-client, use
|
||||||
|
|
||||||
|
$ pip install --upgrade -r requirements-devel.txt
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
$ pip install --upgrade transifex-client==0.11.1b0
|
||||||
|
|
||||||
|
2. Create ~/.transifexrc file:
|
||||||
|
|
||||||
|
$ vim ~/.transifexrc"
|
||||||
|
|
||||||
|
[https://www.transifex.com]
|
||||||
|
hostname = https://www.transifex.com
|
||||||
|
username = <YOUR_USERNAME>
|
||||||
|
password = <YOUR_PASSWOR>
|
||||||
|
""")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
RUNABLE_SCRIPTS = {
|
||||||
|
"update_catalogs": "regenerate .po files of main lang (en).",
|
||||||
|
"commit": "send .po file to transifex ('en' by default).",
|
||||||
|
"fetch": "get .po files from transifex and regenerate .mo files.",
|
||||||
|
"lang_stats": "get stats of local translations",
|
||||||
|
}
|
||||||
|
|
||||||
|
parser = ArgumentParser(description="manage translations in taiga-back between the repo and transifex.",
|
||||||
|
formatter_class=RawTextHelpFormatter)
|
||||||
|
parser.add_argument("cmd", nargs=1,
|
||||||
|
help="\n".join(["{0} - {1}".format(c, h) for c, h in RUNABLE_SCRIPTS.items()]))
|
||||||
|
parser.add_argument("-r", "--resources", action="append",
|
||||||
|
help="limit operation to the specified resources")
|
||||||
|
parser.add_argument("-l", "--languages", action="append",
|
||||||
|
help="limit operation to the specified languages")
|
||||||
|
options = parser.parse_args()
|
||||||
|
|
||||||
|
if options.cmd[0] in RUNABLE_SCRIPTS.keys():
|
||||||
|
eval(options.cmd[0])(options.resources, options.languages)
|
||||||
|
else:
|
||||||
|
print("Available commands are: {}".format(", ".join(RUNABLE_SCRIPTS.keys())))
|
|
@ -15,7 +15,11 @@
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import os.path, sys, os
|
import os.path, sys, os
|
||||||
from django.utils.translation import ugettext_lazy as _
|
|
||||||
|
# This is defined here as a do-nothing function because we can't import
|
||||||
|
# django.utils.translation -- that module depends on the settings.
|
||||||
|
gettext_noop = lambda s: s
|
||||||
|
|
||||||
|
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||||
|
|
||||||
|
@ -26,11 +30,6 @@ ADMINS = (
|
||||||
("Admin", "example@example.com"),
|
("Admin", "example@example.com"),
|
||||||
)
|
)
|
||||||
|
|
||||||
LANGUAGES = (
|
|
||||||
("en", _("English")),
|
|
||||||
("es", _("Spanish")),
|
|
||||||
)
|
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"default": {
|
||||||
"ENGINE": "transaction_hooks.backends.postgresql_psycopg2",
|
"ENGINE": "transaction_hooks.backends.postgresql_psycopg2",
|
||||||
|
@ -60,12 +59,108 @@ IGNORABLE_404_STARTS = ("/phpmyadmin/",)
|
||||||
|
|
||||||
ATOMIC_REQUESTS = True
|
ATOMIC_REQUESTS = True
|
||||||
TIME_ZONE = "UTC"
|
TIME_ZONE = "UTC"
|
||||||
LANGUAGE_CODE = "en"
|
|
||||||
USE_I18N = True
|
|
||||||
USE_L10N = True
|
|
||||||
LOGIN_URL="/auth/login/"
|
LOGIN_URL="/auth/login/"
|
||||||
USE_TZ = True
|
USE_TZ = True
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
USE_L10N = True
|
||||||
|
# Language code for this installation. All choices can be found here:
|
||||||
|
# http://www.i18nguy.com/unicode/language-identifiers.html
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
|
||||||
|
# Languages we provide translations for, out of the box.
|
||||||
|
LANGUAGES = [
|
||||||
|
("af", "Afrikaans"), # Afrikaans
|
||||||
|
("ar", "العربية"), # Arabic
|
||||||
|
("ast", "Asturiano"), # Asturian
|
||||||
|
("az", "Azərbaycan dili"), # Azerbaijani
|
||||||
|
("bg", "Български"), # Bulgarian
|
||||||
|
("be", "Беларуская"), # Belarusian
|
||||||
|
("bn", "বাংলা"), # Bengali
|
||||||
|
("br", "Bretón"), # Breton
|
||||||
|
("bs", "Bosanski"), # Bosnian
|
||||||
|
("ca", "Català"), # Catalan
|
||||||
|
("cs", "Čeština"), # Czech
|
||||||
|
("cy", "Cymraeg"), # Welsh
|
||||||
|
("da", "Dansk"), # Danish
|
||||||
|
("de", "Deutsch"), # German
|
||||||
|
("el", "Ελληνικά"), # Greek
|
||||||
|
("en", "English (US)"), # English
|
||||||
|
("en-au", "English (Australia)"), # Australian English
|
||||||
|
("en-gb", "English (UK)"), # British English
|
||||||
|
("eo", "esperanta"), # Esperanto
|
||||||
|
("es", "Español"), # Spanish
|
||||||
|
("es-ar", "Español (Argentina)"), # Argentinian Spanish
|
||||||
|
("es-mx", "Español (México)"), # Mexican Spanish
|
||||||
|
("es-ni", "Español (Nicaragua)"), # Nicaraguan Spanish
|
||||||
|
("es-ve", "Español (Venezuela)"), # Venezuelan Spanish
|
||||||
|
("et", "Eesti"), # Estonian
|
||||||
|
("eu", "Euskara"), # Basque
|
||||||
|
("fa", "فارسی"), # Persian
|
||||||
|
("fi", "Suomi"), # Finnish
|
||||||
|
("fr", "Français"), # French
|
||||||
|
("fy", "Frysk"), # Frisian
|
||||||
|
("ga", "Irish"), # Irish
|
||||||
|
("gl", "Galego"), # Galician
|
||||||
|
("he", "עברית"), # Hebrew
|
||||||
|
("hi", "हिन्दी"), # Hindi
|
||||||
|
("hr", "Hrvatski"), # Croatian
|
||||||
|
("hu", "Magyar"), # Hungarian
|
||||||
|
("ia", "Interlingua"), # Interlingua
|
||||||
|
("id", "Bahasa Indonesia"), # Indonesian
|
||||||
|
("io", "IDO"), # Ido
|
||||||
|
("is", "Íslenska"), # Icelandic
|
||||||
|
("it", "Italiano"), # Italian
|
||||||
|
("ja", "日本語"), # Japanese
|
||||||
|
("ka", "ქართული"), # Georgian
|
||||||
|
("kk", "Қазақша"), # Kazakh
|
||||||
|
("km", "ភាសាខ្មែរ"), # Khmer
|
||||||
|
("kn", "ಕನ್ನಡ"), # Kannada
|
||||||
|
("ko", "한국어"), # Korean
|
||||||
|
("lb", "Lëtzebuergesch"), # Luxembourgish
|
||||||
|
("lt", "Lietuvių"), # Lithuanian
|
||||||
|
("lv", "Latviešu"), # Latvian
|
||||||
|
("mk", "Македонски"), # Macedonian
|
||||||
|
("ml", "മലയാളം"), # Malayalam
|
||||||
|
("mn", "Монгол"), # Mongolian
|
||||||
|
("mr", "मराठी"), # Marathi
|
||||||
|
("my", "မြန်မာ"), # Burmese
|
||||||
|
("nb", "Norsk (bokmål)"), # Norwegian Bokmal
|
||||||
|
("ne", "नेपाली"), # Nepali
|
||||||
|
("nl", "Nederlands"), # Dutch
|
||||||
|
("nn", "Norsk (nynorsk)"), # Norwegian Nynorsk
|
||||||
|
("os", "Ирон æвзаг"), # Ossetic
|
||||||
|
("pa", "ਪੰਜਾਬੀ"), # Punjabi
|
||||||
|
("pl", "Polski"), # Polish
|
||||||
|
("pt", "Português (Portugal)"), # Portuguese
|
||||||
|
("pt-br", "Português (Brasil)"), # Brazilian Portuguese
|
||||||
|
("ro", "Română"), # Romanian
|
||||||
|
("ru", "Русский"), # Russian
|
||||||
|
("sk", "Slovenčina"), # Slovak
|
||||||
|
("sl", "Slovenščina"), # Slovenian
|
||||||
|
("sq", "Shqip"), # Albanian
|
||||||
|
("sr", "Српски"), # Serbian
|
||||||
|
("sr-latn", "srpski"), # Serbian Latin
|
||||||
|
("sv", "Svenska"), # Swedish
|
||||||
|
("sw", "Kiswahili"), # Swahili
|
||||||
|
("ta", "தமிழ்"), # Tamil
|
||||||
|
("te", "తెలుగు"), # Telugu
|
||||||
|
("th", "ภาษาไทย"), # Thai
|
||||||
|
("tr", "Türkçe"), # Turkish
|
||||||
|
("tt", "татар теле"), # Tatar
|
||||||
|
("udm", "удмурт кыл"), # Udmurt
|
||||||
|
("uk", "Українська"), # Ukrainian
|
||||||
|
("ur", "اردو"), # Urdu
|
||||||
|
("vi", "Tiếng Việt"), # Vietnamese
|
||||||
|
("zh-hans", "中文(简体)"), # Simplified Chinese
|
||||||
|
("zh-hant", "中文(香港)"), # Traditional Chinese
|
||||||
|
]
|
||||||
|
|
||||||
|
# Languages using BiDi (right-to-left) layout
|
||||||
|
LANGUAGES_BIDI = ["he", "ar", "fa", "ur"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
SITES = {
|
SITES = {
|
||||||
"api": {"domain": "localhost:8000", "scheme": "http", "name": "api"},
|
"api": {"domain": "localhost:8000", "scheme": "http", "name": "api"},
|
||||||
"front": {"domain": "localhost:9001", "scheme": "http", "name": "front"},
|
"front": {"domain": "localhost:9001", "scheme": "http", "name": "front"},
|
||||||
|
@ -126,6 +221,7 @@ DEFAULT_FILE_STORAGE = "taiga.base.storage.FileSystemStorage"
|
||||||
|
|
||||||
LOCALE_PATHS = (
|
LOCALE_PATHS = (
|
||||||
os.path.join(BASE_DIR, "locale"),
|
os.path.join(BASE_DIR, "locale"),
|
||||||
|
os.path.join(BASE_DIR, "taiga", "locale"),
|
||||||
)
|
)
|
||||||
|
|
||||||
SECRET_KEY = "aw3+t2r(8(0kkrhg8)gx6i96v5^kv%6cfep9wxfom0%7dy0m9e"
|
SECRET_KEY = "aw3+t2r(8(0kkrhg8)gx6i96v5^kv%6cfep9wxfom0%7dy0m9e"
|
||||||
|
@ -174,6 +270,8 @@ INSTALLED_APPS = [
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
|
|
||||||
"taiga.base",
|
"taiga.base",
|
||||||
|
"taiga.base.api",
|
||||||
|
"taiga.locale",
|
||||||
"taiga.events",
|
"taiga.events",
|
||||||
"taiga.front",
|
"taiga.front",
|
||||||
"taiga.users",
|
"taiga.users",
|
||||||
|
@ -200,7 +298,6 @@ INSTALLED_APPS = [
|
||||||
"taiga.hooks.bitbucket",
|
"taiga.hooks.bitbucket",
|
||||||
"taiga.webhooks",
|
"taiga.webhooks",
|
||||||
|
|
||||||
"rest_framework",
|
|
||||||
"djmail",
|
"djmail",
|
||||||
"django_jinja",
|
"django_jinja",
|
||||||
"django_jinja.contrib._humanize",
|
"django_jinja.contrib._humanize",
|
||||||
|
|
|
@ -17,11 +17,10 @@
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from rest_framework import serializers
|
from taiga.base.api import serializers
|
||||||
|
|
||||||
from taiga.base.api import viewsets
|
from taiga.base.api import viewsets
|
||||||
from taiga.base.decorators import list_route
|
from taiga.base.decorators import list_route
|
||||||
from taiga.base import exceptions as exc
|
from taiga.base import exceptions as exc
|
||||||
|
|
|
@ -35,7 +35,7 @@ fraudulent modifications.
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from rest_framework.authentication import BaseAuthentication
|
from taiga.base.api.authentication import BaseAuthentication
|
||||||
|
|
||||||
from .tokens import get_user_for_token
|
from .tokens import get_user_for_token
|
||||||
|
|
||||||
|
@ -43,7 +43,7 @@ from .tokens import get_user_for_token
|
||||||
class Session(BaseAuthentication):
|
class Session(BaseAuthentication):
|
||||||
"""
|
"""
|
||||||
Session based authentication like the standard
|
Session based authentication like the standard
|
||||||
`rest_framework.authentication.SessionAuthentication`
|
`taiga.base.api.authentication.SessionAuthentication`
|
||||||
but with csrf disabled (for obvious reasons because
|
but with csrf disabled (for obvious reasons because
|
||||||
it is for api.
|
it is for api.
|
||||||
|
|
||||||
|
|
|
@ -14,10 +14,12 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from django.core import validators
|
from django.core import validators
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
|
from taiga.base.api import serializers
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
@ -29,13 +31,13 @@ class BaseRegisterSerializer(serializers.Serializer):
|
||||||
|
|
||||||
def validate_username(self, attrs, source):
|
def validate_username(self, attrs, source):
|
||||||
value = attrs[source]
|
value = attrs[source]
|
||||||
validator = validators.RegexValidator(re.compile('^[\w.-]+$'), "invalid username", "invalid")
|
validator = validators.RegexValidator(re.compile('^[\w.-]+$'), _("invalid username"), "invalid")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
validator(value)
|
validator(value)
|
||||||
except ValidationError:
|
except ValidationError:
|
||||||
raise serializers.ValidationError("Required. 255 characters or fewer. Letters, numbers "
|
raise serializers.ValidationError(_("Required. 255 characters or fewer. Letters, numbers "
|
||||||
"and /./-/_ characters'")
|
"and /./-/_ characters'"))
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -58,7 +58,7 @@ def send_register_email(user) -> bool:
|
||||||
cancel_token = get_token_for_user(user, "cancel_account")
|
cancel_token = get_token_for_user(user, "cancel_account")
|
||||||
context = {"user": user, "cancel_token": cancel_token}
|
context = {"user": user, "cancel_token": cancel_token}
|
||||||
mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail)
|
mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail)
|
||||||
email = mbuilder.registered_user(user.email, context)
|
email = mbuilder.registered_user(user, context)
|
||||||
return bool(email.send())
|
return bool(email.send())
|
||||||
|
|
||||||
|
|
||||||
|
@ -91,7 +91,7 @@ def get_membership_by_token(token:str):
|
||||||
membership_model = apps.get_model("projects", "Membership")
|
membership_model = apps.get_model("projects", "Membership")
|
||||||
qs = membership_model.objects.filter(token=token)
|
qs = membership_model.objects.filter(token=token)
|
||||||
if len(qs) == 0:
|
if len(qs) == 0:
|
||||||
raise exc.NotFound("Token not matches any valid invitation.")
|
raise exc.NotFound(_("Token not matches any valid invitation."))
|
||||||
return qs[0]
|
return qs[0]
|
||||||
|
|
||||||
|
|
||||||
|
@ -119,7 +119,7 @@ def public_register(username:str, password:str, email:str, full_name:str):
|
||||||
try:
|
try:
|
||||||
user.save()
|
user.save()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
raise exc.WrongArguments("User is already register.")
|
raise exc.WrongArguments(_("User is already registered."))
|
||||||
|
|
||||||
send_register_email(user)
|
send_register_email(user)
|
||||||
user_registered_signal.send(sender=user.__class__, user=user)
|
user_registered_signal.send(sender=user.__class__, user=user)
|
||||||
|
@ -143,7 +143,7 @@ def private_register_for_existing_user(token:str, username:str, password:str):
|
||||||
membership.user = user
|
membership.user = user
|
||||||
membership.save(update_fields=["user"])
|
membership.save(update_fields=["user"])
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
raise exc.IntegrityError("Membership with user is already exists.")
|
raise exc.IntegrityError(_("Membership with user is already exists."))
|
||||||
|
|
||||||
send_register_email(user)
|
send_register_email(user)
|
||||||
return user
|
return user
|
||||||
|
|
|
@ -18,6 +18,7 @@ from taiga.base import exceptions as exc
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.core import signing
|
from django.core import signing
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
|
|
||||||
def get_token_for_user(user, scope):
|
def get_token_for_user(user, scope):
|
||||||
|
@ -43,13 +44,13 @@ def get_user_for_token(token, scope, max_age=None):
|
||||||
try:
|
try:
|
||||||
data = signing.loads(token, max_age=max_age)
|
data = signing.loads(token, max_age=max_age)
|
||||||
except signing.BadSignature:
|
except signing.BadSignature:
|
||||||
raise exc.NotAuthenticated("Invalid token")
|
raise exc.NotAuthenticated(_("Invalid token"))
|
||||||
|
|
||||||
model_cls = apps.get_model("users", "User")
|
model_cls = apps.get_model("users", "User")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
user = model_cls.objects.get(pk=data["user_%s_id" % (scope)])
|
user = model_cls.objects.get(pk=data["user_%s_id" % (scope)])
|
||||||
except (model_cls.DoesNotExist, KeyError):
|
except (model_cls.DoesNotExist, KeyError):
|
||||||
raise exc.NotAuthenticated("Invalid token")
|
raise exc.NotAuthenticated(_("Invalid token"))
|
||||||
else:
|
else:
|
||||||
return user
|
return user
|
||||||
|
|
|
@ -17,6 +17,15 @@
|
||||||
# This code is partially taken from django-rest-framework:
|
# This code is partially taken from django-rest-framework:
|
||||||
# Copyright (c) 2011-2014, Tom Christie
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
VERSION = "2.3.13-taiga" # Based on django-resframework 2.3.13
|
||||||
|
|
||||||
|
# Header encoding (see RFC5987)
|
||||||
|
HTTP_HEADER_ENCODING = 'iso-8859-1'
|
||||||
|
|
||||||
|
# Default datetime input and output formats
|
||||||
|
ISO_8601 = 'iso-8601'
|
||||||
|
|
||||||
|
|
||||||
from .viewsets import ModelListViewSet
|
from .viewsets import ModelListViewSet
|
||||||
from .viewsets import ModelCrudViewSet
|
from .viewsets import ModelCrudViewSet
|
||||||
from .viewsets import ModelUpdateRetrieveViewSet
|
from .viewsets import ModelUpdateRetrieveViewSet
|
||||||
|
|
|
@ -0,0 +1,148 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
"""
|
||||||
|
Provides various authentication policies.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from django.contrib.auth import authenticate
|
||||||
|
from django.middleware.csrf import CsrfViewMiddleware
|
||||||
|
|
||||||
|
from taiga.base import exceptions
|
||||||
|
|
||||||
|
from . import HTTP_HEADER_ENCODING
|
||||||
|
|
||||||
|
|
||||||
|
def get_authorization_header(request):
|
||||||
|
"""
|
||||||
|
Return request's 'Authorization:' header, as a bytestring.
|
||||||
|
|
||||||
|
Hide some test client ickyness where the header can be unicode.
|
||||||
|
"""
|
||||||
|
auth = request.META.get('HTTP_AUTHORIZATION', b'')
|
||||||
|
if type(auth) == type(''):
|
||||||
|
# Work around django test client oddness
|
||||||
|
auth = auth.encode(HTTP_HEADER_ENCODING)
|
||||||
|
return auth
|
||||||
|
|
||||||
|
|
||||||
|
class CSRFCheck(CsrfViewMiddleware):
|
||||||
|
def _reject(self, request, reason):
|
||||||
|
# Return the failure reason instead of an HttpResponse
|
||||||
|
return reason
|
||||||
|
|
||||||
|
|
||||||
|
class BaseAuthentication(object):
|
||||||
|
"""
|
||||||
|
All authentication classes should extend BaseAuthentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
"""
|
||||||
|
Authenticate the request and return a two-tuple of (user, token).
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(".authenticate() must be overridden.")
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
"""
|
||||||
|
Return a string to be used as the value of the `WWW-Authenticate`
|
||||||
|
header in a `401 Unauthenticated` response, or `None` if the
|
||||||
|
authentication scheme should return `403 Permission Denied` responses.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class BasicAuthentication(BaseAuthentication):
|
||||||
|
"""
|
||||||
|
HTTP Basic authentication against username/password.
|
||||||
|
"""
|
||||||
|
www_authenticate_realm = 'api'
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
"""
|
||||||
|
Returns a `User` if a correct username and password have been supplied
|
||||||
|
using HTTP Basic authentication. Otherwise returns `None`.
|
||||||
|
"""
|
||||||
|
auth = get_authorization_header(request).split()
|
||||||
|
|
||||||
|
if not auth or auth[0].lower() != b'basic':
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(auth) == 1:
|
||||||
|
msg = 'Invalid basic header. No credentials provided.'
|
||||||
|
raise exceptions.AuthenticationFailed(msg)
|
||||||
|
elif len(auth) > 2:
|
||||||
|
msg = 'Invalid basic header. Credentials string should not contain spaces.'
|
||||||
|
raise exceptions.AuthenticationFailed(msg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
auth_parts = base64.b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).partition(':')
|
||||||
|
except (TypeError, UnicodeDecodeError):
|
||||||
|
msg = 'Invalid basic header. Credentials not correctly base64 encoded'
|
||||||
|
raise exceptions.AuthenticationFailed(msg)
|
||||||
|
|
||||||
|
userid, password = auth_parts[0], auth_parts[2]
|
||||||
|
return self.authenticate_credentials(userid, password)
|
||||||
|
|
||||||
|
def authenticate_credentials(self, userid, password):
|
||||||
|
"""
|
||||||
|
Authenticate the userid and password against username and password.
|
||||||
|
"""
|
||||||
|
user = authenticate(username=userid, password=password)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise exceptions.AuthenticationFailed('Invalid username/password')
|
||||||
|
return (user, None)
|
||||||
|
|
||||||
|
def authenticate_header(self, request):
|
||||||
|
return 'Basic realm="%s"' % self.www_authenticate_realm
|
||||||
|
|
||||||
|
|
||||||
|
class SessionAuthentication(BaseAuthentication):
|
||||||
|
"""
|
||||||
|
Use Django's session framework for authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
"""
|
||||||
|
Returns a `User` if the request session currently has a logged in user.
|
||||||
|
Otherwise returns `None`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Get the underlying HttpRequest object
|
||||||
|
request = request._request
|
||||||
|
user = getattr(request, 'user', None)
|
||||||
|
|
||||||
|
# Unauthenticated, CSRF validation not required
|
||||||
|
if not user or not user.is_active:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self.enforce_csrf(request)
|
||||||
|
|
||||||
|
# CSRF passed with authenticated user
|
||||||
|
return (user, None)
|
||||||
|
|
||||||
|
def enforce_csrf(self, request):
|
||||||
|
"""
|
||||||
|
Enforce CSRF validation for session based authentication.
|
||||||
|
"""
|
||||||
|
reason = CSRFCheck().process_view(request, None, (), {})
|
||||||
|
if reason:
|
||||||
|
# CSRF failed, bail with explicit error message
|
||||||
|
raise exceptions.AuthenticationFailed('CSRF Failed: %s' % reason)
|
File diff suppressed because it is too large
Load Diff
|
@ -17,33 +17,18 @@
|
||||||
# This code is partially taken from django-rest-framework:
|
# This code is partially taken from django-rest-framework:
|
||||||
# Copyright (c) 2011-2014, Tom Christie
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
import warnings
|
|
||||||
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.core.paginator import Paginator, InvalidPage
|
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.utils.translation import ugettext as _
|
|
||||||
|
|
||||||
from rest_framework.settings import api_settings
|
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
from . import mixins
|
from . import mixins
|
||||||
|
from . import pagination
|
||||||
|
from .settings import api_settings
|
||||||
from .utils import get_object_or_404
|
from .utils import get_object_or_404
|
||||||
|
|
||||||
|
|
||||||
def strict_positive_int(integer_string, cutoff=None):
|
class GenericAPIView(pagination.PaginationMixin,
|
||||||
"""
|
views.APIView):
|
||||||
Cast a string to a strictly positive integer.
|
|
||||||
"""
|
|
||||||
ret = int(integer_string)
|
|
||||||
if ret <= 0:
|
|
||||||
raise ValueError()
|
|
||||||
if cutoff:
|
|
||||||
ret = min(ret, cutoff)
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
class GenericAPIView(views.APIView):
|
|
||||||
"""
|
"""
|
||||||
Base class for all other generic views.
|
Base class for all other generic views.
|
||||||
"""
|
"""
|
||||||
|
@ -63,20 +48,12 @@ class GenericAPIView(views.APIView):
|
||||||
lookup_field = 'pk'
|
lookup_field = 'pk'
|
||||||
lookup_url_kwarg = None
|
lookup_url_kwarg = None
|
||||||
|
|
||||||
# Pagination settings
|
|
||||||
paginate_by = api_settings.PAGINATE_BY
|
|
||||||
paginate_by_param = api_settings.PAGINATE_BY_PARAM
|
|
||||||
max_paginate_by = api_settings.MAX_PAGINATE_BY
|
|
||||||
pagination_serializer_class = api_settings.DEFAULT_PAGINATION_SERIALIZER_CLASS
|
|
||||||
page_kwarg = 'page'
|
|
||||||
|
|
||||||
# The filter backend classes to use for queryset filtering
|
# The filter backend classes to use for queryset filtering
|
||||||
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
|
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
|
||||||
|
|
||||||
# The following attributes may be subject to change,
|
# The following attributes may be subject to change,
|
||||||
# and should be considered private API.
|
# and should be considered private API.
|
||||||
model_serializer_class = api_settings.DEFAULT_MODEL_SERIALIZER_CLASS
|
model_serializer_class = api_settings.DEFAULT_MODEL_SERIALIZER_CLASS
|
||||||
paginator_class = Paginator
|
|
||||||
|
|
||||||
######################################
|
######################################
|
||||||
# These are pending deprecation...
|
# These are pending deprecation...
|
||||||
|
@ -107,70 +84,6 @@ class GenericAPIView(views.APIView):
|
||||||
return serializer_class(instance, data=data, files=files,
|
return serializer_class(instance, data=data, files=files,
|
||||||
many=many, partial=partial, context=context)
|
many=many, partial=partial, context=context)
|
||||||
|
|
||||||
def get_pagination_serializer(self, page):
|
|
||||||
"""
|
|
||||||
Return a serializer instance to use with paginated data.
|
|
||||||
"""
|
|
||||||
class SerializerClass(self.pagination_serializer_class):
|
|
||||||
class Meta:
|
|
||||||
object_serializer_class = self.get_serializer_class()
|
|
||||||
|
|
||||||
pagination_serializer_class = SerializerClass
|
|
||||||
context = self.get_serializer_context()
|
|
||||||
return pagination_serializer_class(instance=page, context=context)
|
|
||||||
|
|
||||||
def paginate_queryset(self, queryset, page_size=None):
|
|
||||||
"""
|
|
||||||
Paginate a queryset if required, either returning a page object,
|
|
||||||
or `None` if pagination is not configured for this view.
|
|
||||||
"""
|
|
||||||
deprecated_style = False
|
|
||||||
if page_size is not None:
|
|
||||||
warnings.warn('The `page_size` parameter to `paginate_queryset()` '
|
|
||||||
'is due to be deprecated. '
|
|
||||||
'Note that the return style of this method is also '
|
|
||||||
'changed, and will simply return a page object '
|
|
||||||
'when called without a `page_size` argument.',
|
|
||||||
PendingDeprecationWarning, stacklevel=2)
|
|
||||||
deprecated_style = True
|
|
||||||
else:
|
|
||||||
# Determine the required page size.
|
|
||||||
# If pagination is not configured, simply return None.
|
|
||||||
page_size = self.get_paginate_by()
|
|
||||||
if not page_size:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if not self.allow_empty:
|
|
||||||
warnings.warn(
|
|
||||||
'The `allow_empty` parameter is due to be deprecated. '
|
|
||||||
'To use `allow_empty=False` style behavior, You should override '
|
|
||||||
'`get_queryset()` and explicitly raise a 404 on empty querysets.',
|
|
||||||
PendingDeprecationWarning, stacklevel=2
|
|
||||||
)
|
|
||||||
|
|
||||||
paginator = self.paginator_class(queryset, page_size,
|
|
||||||
allow_empty_first_page=self.allow_empty)
|
|
||||||
page_kwarg = self.kwargs.get(self.page_kwarg)
|
|
||||||
page_query_param = self.request.QUERY_PARAMS.get(self.page_kwarg)
|
|
||||||
page = page_kwarg or page_query_param or 1
|
|
||||||
try:
|
|
||||||
page_number = paginator.validate_number(page)
|
|
||||||
except InvalidPage:
|
|
||||||
if page == 'last':
|
|
||||||
page_number = paginator.num_pages
|
|
||||||
else:
|
|
||||||
raise Http404(_("Page is not 'last', nor can it be converted to an int."))
|
|
||||||
try:
|
|
||||||
page = paginator.page(page_number)
|
|
||||||
except InvalidPage as e:
|
|
||||||
raise Http404(_('Invalid page (%(page_number)s): %(message)s') % {
|
|
||||||
'page_number': page_number,
|
|
||||||
'message': str(e)
|
|
||||||
})
|
|
||||||
|
|
||||||
if deprecated_style:
|
|
||||||
return (paginator, page, page.object_list, page.has_other_pages())
|
|
||||||
return page
|
|
||||||
|
|
||||||
def filter_queryset(self, queryset):
|
def filter_queryset(self, queryset):
|
||||||
"""
|
"""
|
||||||
|
@ -202,29 +115,6 @@ class GenericAPIView(views.APIView):
|
||||||
# that you may want to override for more complex cases. #
|
# that you may want to override for more complex cases. #
|
||||||
###########################################################
|
###########################################################
|
||||||
|
|
||||||
def get_paginate_by(self, queryset=None):
|
|
||||||
"""
|
|
||||||
Return the size of pages to use with pagination.
|
|
||||||
|
|
||||||
If `PAGINATE_BY_PARAM` is set it will attempt to get the page size
|
|
||||||
from a named query parameter in the url, eg. ?page_size=100
|
|
||||||
|
|
||||||
Otherwise defaults to using `self.paginate_by`.
|
|
||||||
"""
|
|
||||||
if queryset is not None:
|
|
||||||
raise RuntimeError('The `queryset` parameter to `get_paginate_by()` '
|
|
||||||
'is due to be deprecated.')
|
|
||||||
if self.paginate_by_param:
|
|
||||||
try:
|
|
||||||
return strict_positive_int(
|
|
||||||
self.request.QUERY_PARAMS[self.paginate_by_param],
|
|
||||||
cutoff=self.max_paginate_by
|
|
||||||
)
|
|
||||||
except (KeyError, ValueError):
|
|
||||||
pass
|
|
||||||
|
|
||||||
return self.paginate_by
|
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
if self.action == "list" and hasattr(self, "list_serializer_class"):
|
if self.action == "list" and hasattr(self, "list_serializer_class"):
|
||||||
return self.list_serializer_class
|
return self.list_serializer_class
|
||||||
|
@ -233,11 +123,9 @@ class GenericAPIView(views.APIView):
|
||||||
if serializer_class is not None:
|
if serializer_class is not None:
|
||||||
return serializer_class
|
return serializer_class
|
||||||
|
|
||||||
assert self.model is not None, \
|
assert self.model is not None, ("'%s' should either include a 'serializer_class' attribute, "
|
||||||
"'%s' should either include a 'serializer_class' attribute, " \
|
"or use the 'model' attribute as a shortcut for "
|
||||||
"or use the 'model' attribute as a shortcut for " \
|
"automatically generating a serializer class." % self.__class__.__name__)
|
||||||
"automatically generating a serializer class." \
|
|
||||||
% self.__class__.__name__
|
|
||||||
|
|
||||||
class DefaultSerializer(self.model_serializer_class):
|
class DefaultSerializer(self.model_serializer_class):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -261,7 +149,7 @@ class GenericAPIView(views.APIView):
|
||||||
if self.model is not None:
|
if self.model is not None:
|
||||||
return self.model._default_manager.all()
|
return self.model._default_manager.all()
|
||||||
|
|
||||||
raise ImproperlyConfigured("'%s' must define 'queryset' or 'model'" % self.__class__.__name__)
|
raise ImproperlyConfigured(("'%s' must define 'queryset' or 'model'" % self.__class__.__name__))
|
||||||
|
|
||||||
def get_object(self, queryset=None):
|
def get_object(self, queryset=None):
|
||||||
"""
|
"""
|
||||||
|
@ -289,18 +177,16 @@ class GenericAPIView(views.APIView):
|
||||||
if lookup is not None:
|
if lookup is not None:
|
||||||
filter_kwargs = {self.lookup_field: lookup}
|
filter_kwargs = {self.lookup_field: lookup}
|
||||||
elif pk is not None and self.lookup_field == 'pk':
|
elif pk is not None and self.lookup_field == 'pk':
|
||||||
raise RuntimeError('The `pk_url_kwarg` attribute is due to be deprecated. '
|
raise RuntimeError(('The `pk_url_kwarg` attribute is due to be deprecated. '
|
||||||
'Use the `lookup_field` attribute instead')
|
'Use the `lookup_field` attribute instead'))
|
||||||
elif slug is not None and self.lookup_field == 'pk':
|
elif slug is not None and self.lookup_field == 'pk':
|
||||||
raise RuntimeError('The `slug_url_kwarg` attribute is due to be deprecated. '
|
raise RuntimeError(('The `slug_url_kwarg` attribute is due to be deprecated. '
|
||||||
'Use the `lookup_field` attribute instead')
|
'Use the `lookup_field` attribute instead'))
|
||||||
else:
|
else:
|
||||||
raise ImproperlyConfigured(
|
raise ImproperlyConfigured(('Expected view %s to be called with a URL keyword argument '
|
||||||
'Expected view %s to be called with a URL keyword argument '
|
'named "%s". Fix your URL conf, or set the `.lookup_field` '
|
||||||
'named "%s". Fix your URL conf, or set the `.lookup_field` '
|
'attribute on the view correctly.' %
|
||||||
'attribute on the view correctly.' %
|
(self.__class__.__name__, self.lookup_field)))
|
||||||
(self.__class__.__name__, self.lookup_field)
|
|
||||||
)
|
|
||||||
|
|
||||||
obj = get_object_or_404(queryset, **filter_kwargs)
|
obj = get_object_or_404(queryset, **filter_kwargs)
|
||||||
return obj
|
return obj
|
||||||
|
|
|
@ -22,10 +22,11 @@ import warnings
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
from django.db import transaction as tx
|
from django.db import transaction as tx
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from taiga.base import response
|
from taiga.base import response
|
||||||
from rest_framework.settings import api_settings
|
|
||||||
|
|
||||||
|
from .settings import api_settings
|
||||||
from .utils import get_object_or_404
|
from .utils import get_object_or_404
|
||||||
|
|
||||||
|
|
||||||
|
@ -94,12 +95,10 @@ class ListModelMixin(object):
|
||||||
# Default is to allow empty querysets. This can be altered by setting
|
# Default is to allow empty querysets. This can be altered by setting
|
||||||
# `.allow_empty = False`, to raise 404 errors on empty querysets.
|
# `.allow_empty = False`, to raise 404 errors on empty querysets.
|
||||||
if not self.allow_empty and not self.object_list:
|
if not self.allow_empty and not self.object_list:
|
||||||
warnings.warn(
|
warnings.warn(_('The `allow_empty` parameter is due to be deprecated. '
|
||||||
'The `allow_empty` parameter is due to be deprecated. '
|
'To use `allow_empty=False` style behavior, You should override '
|
||||||
'To use `allow_empty=False` style behavior, You should override '
|
'`get_queryset()` and explicitly raise a 404 on empty querysets.'),
|
||||||
'`get_queryset()` and explicitly raise a 404 on empty querysets.',
|
PendingDeprecationWarning)
|
||||||
PendingDeprecationWarning
|
|
||||||
)
|
|
||||||
class_name = self.__class__.__name__
|
class_name = self.__class__.__name__
|
||||||
error_msg = self.empty_error % {'class_name': class_name}
|
error_msg = self.empty_error % {'class_name': class_name}
|
||||||
raise Http404(error_msg)
|
raise Http404(error_msg)
|
||||||
|
|
|
@ -0,0 +1,111 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
"""
|
||||||
|
Content negotiation deals with selecting an appropriate renderer given the
|
||||||
|
incoming request. Typically this will be based on the request's Accept header.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.http import Http404
|
||||||
|
|
||||||
|
from taiga.base import exceptions
|
||||||
|
from .settings import api_settings
|
||||||
|
|
||||||
|
from .utils.mediatypes import order_by_precedence
|
||||||
|
from .utils.mediatypes import media_type_matches
|
||||||
|
from .utils.mediatypes import _MediaType
|
||||||
|
|
||||||
|
|
||||||
|
class BaseContentNegotiation(object):
|
||||||
|
def select_parser(self, request, parsers):
|
||||||
|
raise NotImplementedError(".select_parser() must be implemented")
|
||||||
|
|
||||||
|
def select_renderer(self, request, renderers, format_suffix=None):
|
||||||
|
raise NotImplementedError(".select_renderer() must be implemented")
|
||||||
|
|
||||||
|
|
||||||
|
class DefaultContentNegotiation(BaseContentNegotiation):
|
||||||
|
settings = api_settings
|
||||||
|
|
||||||
|
def select_parser(self, request, parsers):
|
||||||
|
"""
|
||||||
|
Given a list of parsers and a media type, return the appropriate
|
||||||
|
parser to handle the incoming request.
|
||||||
|
"""
|
||||||
|
for parser in parsers:
|
||||||
|
if media_type_matches(parser.media_type, request.content_type):
|
||||||
|
return parser
|
||||||
|
return None
|
||||||
|
|
||||||
|
def select_renderer(self, request, renderers, format_suffix=None):
|
||||||
|
"""
|
||||||
|
Given a request and a list of renderers, return a two-tuple of:
|
||||||
|
(renderer, media type).
|
||||||
|
"""
|
||||||
|
# Allow URL style format override. eg. "?format=json
|
||||||
|
format_query_param = self.settings.URL_FORMAT_OVERRIDE
|
||||||
|
format = format_suffix or request.QUERY_PARAMS.get(format_query_param)
|
||||||
|
|
||||||
|
if format:
|
||||||
|
renderers = self.filter_renderers(renderers, format)
|
||||||
|
|
||||||
|
accepts = self.get_accept_list(request)
|
||||||
|
|
||||||
|
# Check the acceptable media types against each renderer,
|
||||||
|
# attempting more specific media types first
|
||||||
|
# NB. The inner loop here isni't as bad as it first looks :)
|
||||||
|
# Worst case is we"re looping over len(accept_list) * len(self.renderers)
|
||||||
|
for media_type_set in order_by_precedence(accepts):
|
||||||
|
for renderer in renderers:
|
||||||
|
for media_type in media_type_set:
|
||||||
|
if media_type_matches(renderer.media_type, media_type):
|
||||||
|
# Return the most specific media type as accepted.
|
||||||
|
if (_MediaType(renderer.media_type).precedence >
|
||||||
|
_MediaType(media_type).precedence):
|
||||||
|
# Eg client requests "*/*"
|
||||||
|
# Accepted media type is "application/json"
|
||||||
|
return renderer, renderer.media_type
|
||||||
|
else:
|
||||||
|
# Eg client requests "application/json; indent=8"
|
||||||
|
# Accepted media type is "application/json; indent=8"
|
||||||
|
return renderer, media_type
|
||||||
|
|
||||||
|
raise exceptions.NotAcceptable(available_renderers=renderers)
|
||||||
|
|
||||||
|
def filter_renderers(self, renderers, format):
|
||||||
|
"""
|
||||||
|
If there is a ".json" style format suffix, filter the renderers
|
||||||
|
so that we only negotiation against those that accept that format.
|
||||||
|
"""
|
||||||
|
renderers = [renderer for renderer in renderers
|
||||||
|
if renderer.format == format]
|
||||||
|
if not renderers:
|
||||||
|
raise Http404
|
||||||
|
return renderers
|
||||||
|
|
||||||
|
def get_accept_list(self, request):
|
||||||
|
"""
|
||||||
|
Given the incoming request, return a tokenised list of media
|
||||||
|
type strings.
|
||||||
|
|
||||||
|
Allows URL style accept override. eg. "?accept=application/json"
|
||||||
|
"""
|
||||||
|
header = request.META.get("HTTP_ACCEPT", "*/*")
|
||||||
|
header = request.QUERY_PARAMS.get(self.settings.URL_ACCEPT_OVERRIDE, header)
|
||||||
|
return [token.strip() for token in header.split(",")]
|
|
@ -14,19 +14,112 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from rest_framework.templatetags.rest_framework import replace_query_param
|
from django.core.paginator import Paginator, InvalidPage
|
||||||
|
from django.http import Http404
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
|
from .settings import api_settings
|
||||||
|
from .templatetags.api import replace_query_param
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
|
||||||
|
|
||||||
class ConditionalPaginationMixin(object):
|
def strict_positive_int(integer_string, cutoff=None):
|
||||||
def get_paginate_by(self, *args, **kwargs):
|
"""
|
||||||
|
Cast a string to a strictly positive integer.
|
||||||
|
"""
|
||||||
|
ret = int(integer_string)
|
||||||
|
if ret <= 0:
|
||||||
|
raise ValueError()
|
||||||
|
if cutoff:
|
||||||
|
ret = min(ret, cutoff)
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class PaginationMixin(object):
|
||||||
|
# Pagination settings
|
||||||
|
paginate_by = api_settings.PAGINATE_BY
|
||||||
|
paginate_by_param = api_settings.PAGINATE_BY_PARAM
|
||||||
|
max_paginate_by = api_settings.MAX_PAGINATE_BY
|
||||||
|
page_kwarg = 'page'
|
||||||
|
paginator_class = Paginator
|
||||||
|
|
||||||
|
def get_paginate_by(self, queryset=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Return the size of pages to use with pagination.
|
||||||
|
|
||||||
|
If `PAGINATE_BY_PARAM` is set it will attempt to get the page size
|
||||||
|
from a named query parameter in the url, eg. ?page_size=100
|
||||||
|
|
||||||
|
Otherwise defaults to using `self.paginate_by`.
|
||||||
|
"""
|
||||||
if "HTTP_X_DISABLE_PAGINATION" in self.request.META:
|
if "HTTP_X_DISABLE_PAGINATION" in self.request.META:
|
||||||
return None
|
return None
|
||||||
return super().get_paginate_by(*args, **kwargs)
|
|
||||||
|
|
||||||
|
if queryset is not None:
|
||||||
|
warnings.warn('The `queryset` parameter to `get_paginate_by()` '
|
||||||
|
'is due to be deprecated.',
|
||||||
|
PendingDeprecationWarning, stacklevel=2)
|
||||||
|
|
||||||
|
if self.paginate_by_param:
|
||||||
|
try:
|
||||||
|
return strict_positive_int(
|
||||||
|
self.request.QUERY_PARAMS[self.paginate_by_param],
|
||||||
|
cutoff=self.max_paginate_by
|
||||||
|
)
|
||||||
|
except (KeyError, ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return self.paginate_by
|
||||||
|
|
||||||
class HeadersPaginationMixin(object):
|
|
||||||
def paginate_queryset(self, queryset, page_size=None):
|
def paginate_queryset(self, queryset, page_size=None):
|
||||||
page = super().paginate_queryset(queryset=queryset, page_size=page_size)
|
"""
|
||||||
|
Paginate a queryset if required, either returning a page object,
|
||||||
|
or `None` if pagination is not configured for this view.
|
||||||
|
"""
|
||||||
|
deprecated_style = False
|
||||||
|
if page_size is not None:
|
||||||
|
warnings.warn('The `page_size` parameter to `paginate_queryset()` '
|
||||||
|
'is due to be deprecated. '
|
||||||
|
'Note that the return style of this method is also '
|
||||||
|
'changed, and will simply return a page object '
|
||||||
|
'when called without a `page_size` argument.',
|
||||||
|
PendingDeprecationWarning, stacklevel=2)
|
||||||
|
deprecated_style = True
|
||||||
|
else:
|
||||||
|
# Determine the required page size.
|
||||||
|
# If pagination is not configured, simply return None.
|
||||||
|
page_size = self.get_paginate_by()
|
||||||
|
if not page_size:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not self.allow_empty:
|
||||||
|
warnings.warn(
|
||||||
|
'The `allow_empty` parameter is due to be deprecated. '
|
||||||
|
'To use `allow_empty=False` style behavior, You should override '
|
||||||
|
'`get_queryset()` and explicitly raise a 404 on empty querysets.',
|
||||||
|
PendingDeprecationWarning, stacklevel=2
|
||||||
|
)
|
||||||
|
|
||||||
|
paginator = self.paginator_class(queryset, page_size,
|
||||||
|
allow_empty_first_page=self.allow_empty)
|
||||||
|
page_kwarg = self.kwargs.get(self.page_kwarg)
|
||||||
|
page_query_param = self.request.QUERY_PARAMS.get(self.page_kwarg)
|
||||||
|
page = page_kwarg or page_query_param or 1
|
||||||
|
try:
|
||||||
|
page_number = paginator.validate_number(page)
|
||||||
|
except InvalidPage:
|
||||||
|
if page == 'last':
|
||||||
|
page_number = paginator.num_pages
|
||||||
|
else:
|
||||||
|
raise Http404(_("Page is not 'last', nor can it be converted to an int."))
|
||||||
|
try:
|
||||||
|
page = paginator.page(page_number)
|
||||||
|
except InvalidPage as e:
|
||||||
|
raise Http404(_('Invalid page (%(page_number)s): %(message)s') % {
|
||||||
|
'page_number': page_number,
|
||||||
|
'message': str(e)
|
||||||
|
})
|
||||||
|
|
||||||
if page is None:
|
if page is None:
|
||||||
return page
|
return page
|
||||||
|
|
|
@ -0,0 +1,220 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
"""
|
||||||
|
Parsers are used to parse the content of incoming HTTP requests.
|
||||||
|
|
||||||
|
They give us a generic way of being able to handle various media types
|
||||||
|
on the request, such as form content or json encoded data.
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.files.uploadhandler import StopFutureHandlers
|
||||||
|
from django.http import QueryDict
|
||||||
|
from django.http.multipartparser import MultiPartParser as DjangoMultiPartParser
|
||||||
|
from django.http.multipartparser import MultiPartParserError, parse_header, ChunkIter
|
||||||
|
|
||||||
|
from django.utils import six
|
||||||
|
|
||||||
|
from taiga.base.exceptions import ParseError
|
||||||
|
from taiga.base.api import renderers
|
||||||
|
|
||||||
|
import json
|
||||||
|
import datetime
|
||||||
|
import decimal
|
||||||
|
|
||||||
|
|
||||||
|
class DataAndFiles(object):
|
||||||
|
def __init__(self, data, files):
|
||||||
|
self.data = data
|
||||||
|
self.files = files
|
||||||
|
|
||||||
|
|
||||||
|
class BaseParser(object):
|
||||||
|
"""
|
||||||
|
All parsers should extend `BaseParser`, specifying a `media_type`
|
||||||
|
attribute, and overriding the `.parse()` method.
|
||||||
|
"""
|
||||||
|
|
||||||
|
media_type = None
|
||||||
|
|
||||||
|
def parse(self, stream, media_type=None, parser_context=None):
|
||||||
|
"""
|
||||||
|
Given a stream to read from, return the parsed representation.
|
||||||
|
Should return parsed data, or a `DataAndFiles` object consisting of the
|
||||||
|
parsed data and files.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(".parse() must be overridden.")
|
||||||
|
|
||||||
|
|
||||||
|
class JSONParser(BaseParser):
|
||||||
|
"""
|
||||||
|
Parses JSON-serialized data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
media_type = "application/json"
|
||||||
|
renderer_class = renderers.UnicodeJSONRenderer
|
||||||
|
|
||||||
|
def parse(self, stream, media_type=None, parser_context=None):
|
||||||
|
"""
|
||||||
|
Parses the incoming bytestream as JSON and returns the resulting data.
|
||||||
|
"""
|
||||||
|
parser_context = parser_context or {}
|
||||||
|
encoding = parser_context.get("encoding", settings.DEFAULT_CHARSET)
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = stream.read().decode(encoding)
|
||||||
|
return json.loads(data)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ParseError("JSON parse error - %s" % six.text_type(exc))
|
||||||
|
|
||||||
|
|
||||||
|
class FormParser(BaseParser):
|
||||||
|
"""
|
||||||
|
Parser for form data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
media_type = "application/x-www-form-urlencoded"
|
||||||
|
|
||||||
|
def parse(self, stream, media_type=None, parser_context=None):
|
||||||
|
"""
|
||||||
|
Parses the incoming bytestream as a URL encoded form,
|
||||||
|
and returns the resulting QueryDict.
|
||||||
|
"""
|
||||||
|
parser_context = parser_context or {}
|
||||||
|
encoding = parser_context.get("encoding", settings.DEFAULT_CHARSET)
|
||||||
|
data = QueryDict(stream.read(), encoding=encoding)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class MultiPartParser(BaseParser):
|
||||||
|
"""
|
||||||
|
Parser for multipart form data, which may include file data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
media_type = "multipart/form-data"
|
||||||
|
|
||||||
|
def parse(self, stream, media_type=None, parser_context=None):
|
||||||
|
"""
|
||||||
|
Parses the incoming bytestream as a multipart encoded form,
|
||||||
|
and returns a DataAndFiles object.
|
||||||
|
|
||||||
|
`.data` will be a `QueryDict` containing all the form parameters.
|
||||||
|
`.files` will be a `QueryDict` containing all the form files.
|
||||||
|
"""
|
||||||
|
parser_context = parser_context or {}
|
||||||
|
request = parser_context["request"]
|
||||||
|
encoding = parser_context.get("encoding", settings.DEFAULT_CHARSET)
|
||||||
|
meta = request.META.copy()
|
||||||
|
meta["CONTENT_TYPE"] = media_type
|
||||||
|
upload_handlers = request.upload_handlers
|
||||||
|
|
||||||
|
try:
|
||||||
|
parser = DjangoMultiPartParser(meta, stream, upload_handlers, encoding)
|
||||||
|
data, files = parser.parse()
|
||||||
|
return DataAndFiles(data, files)
|
||||||
|
except MultiPartParserError as exc:
|
||||||
|
raise ParseError("Multipart form parse error - %s" % str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
class FileUploadParser(BaseParser):
|
||||||
|
"""
|
||||||
|
Parser for file upload data.
|
||||||
|
"""
|
||||||
|
media_type = "*/*"
|
||||||
|
|
||||||
|
def parse(self, stream, media_type=None, parser_context=None):
|
||||||
|
"""
|
||||||
|
Treats the incoming bytestream as a raw file upload and returns
|
||||||
|
a `DateAndFiles` object.
|
||||||
|
|
||||||
|
`.data` will be None (we expect request body to be a file content).
|
||||||
|
`.files` will be a `QueryDict` containing one "file" element.
|
||||||
|
"""
|
||||||
|
|
||||||
|
parser_context = parser_context or {}
|
||||||
|
request = parser_context["request"]
|
||||||
|
encoding = parser_context.get("encoding", settings.DEFAULT_CHARSET)
|
||||||
|
meta = request.META
|
||||||
|
upload_handlers = request.upload_handlers
|
||||||
|
filename = self.get_filename(stream, media_type, parser_context)
|
||||||
|
|
||||||
|
# Note that this code is extracted from Django's handling of
|
||||||
|
# file uploads in MultiPartParser.
|
||||||
|
content_type = meta.get("HTTP_CONTENT_TYPE",
|
||||||
|
meta.get("CONTENT_TYPE", ""))
|
||||||
|
try:
|
||||||
|
content_length = int(meta.get("HTTP_CONTENT_LENGTH",
|
||||||
|
meta.get("CONTENT_LENGTH", 0)))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
content_length = None
|
||||||
|
|
||||||
|
# See if the handler will want to take care of the parsing.
|
||||||
|
for handler in upload_handlers:
|
||||||
|
result = handler.handle_raw_input(None,
|
||||||
|
meta,
|
||||||
|
content_length,
|
||||||
|
None,
|
||||||
|
encoding)
|
||||||
|
if result is not None:
|
||||||
|
return DataAndFiles(None, {"file": result[1]})
|
||||||
|
|
||||||
|
# This is the standard case.
|
||||||
|
possible_sizes = [x.chunk_size for x in upload_handlers if x.chunk_size]
|
||||||
|
chunk_size = min([2 ** 31 - 4] + possible_sizes)
|
||||||
|
chunks = ChunkIter(stream, chunk_size)
|
||||||
|
counters = [0] * len(upload_handlers)
|
||||||
|
|
||||||
|
for handler in upload_handlers:
|
||||||
|
try:
|
||||||
|
handler.new_file(None, filename, content_type,
|
||||||
|
content_length, encoding)
|
||||||
|
except StopFutureHandlers:
|
||||||
|
break
|
||||||
|
|
||||||
|
for chunk in chunks:
|
||||||
|
for i, handler in enumerate(upload_handlers):
|
||||||
|
chunk_length = len(chunk)
|
||||||
|
chunk = handler.receive_data_chunk(chunk, counters[i])
|
||||||
|
counters[i] += chunk_length
|
||||||
|
if chunk is None:
|
||||||
|
break
|
||||||
|
|
||||||
|
for i, handler in enumerate(upload_handlers):
|
||||||
|
file_obj = handler.file_complete(counters[i])
|
||||||
|
if file_obj:
|
||||||
|
return DataAndFiles(None, {"file": file_obj})
|
||||||
|
raise ParseError("FileUpload parse error - "
|
||||||
|
"none of upload handlers can handle the stream")
|
||||||
|
|
||||||
|
def get_filename(self, stream, media_type, parser_context):
|
||||||
|
"""
|
||||||
|
Detects the uploaded file name. First searches a "filename" url kwarg.
|
||||||
|
Then tries to parse Content-Disposition header.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return parser_context["kwargs"]["filename"]
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
meta = parser_context["request"].META
|
||||||
|
disposition = parse_header(meta["HTTP_CONTENT_DISPOSITION"])
|
||||||
|
return disposition[1]["filename"]
|
||||||
|
except (AttributeError, KeyError):
|
||||||
|
pass
|
|
@ -20,6 +20,7 @@ from taiga.base.utils import sequence as sq
|
||||||
from taiga.permissions.service import user_has_perm, is_project_owner
|
from taiga.permissions.service import user_has_perm, is_project_owner
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
######################################################################
|
######################################################################
|
||||||
# Base permissiones definition
|
# Base permissiones definition
|
||||||
|
@ -57,7 +58,7 @@ class ResourcePermission(object):
|
||||||
elif inspect.isclass(permset) and issubclass(permset, PermissionComponent):
|
elif inspect.isclass(permset) and issubclass(permset, PermissionComponent):
|
||||||
permset = permset()
|
permset = permset()
|
||||||
else:
|
else:
|
||||||
raise RuntimeError("Invalid permission definition.")
|
raise RuntimeError(_("Invalid permission definition."))
|
||||||
|
|
||||||
if self.global_perms:
|
if self.global_perms:
|
||||||
permset = (self.global_perms & permset)
|
permset = (self.global_perms & permset)
|
||||||
|
|
|
@ -0,0 +1,628 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
"""
|
||||||
|
Serializer fields that deal with relationships.
|
||||||
|
|
||||||
|
These fields allow you to specify the style that should be used to represent
|
||||||
|
model relationships, including hyperlinks, primary keys, or slugs.
|
||||||
|
"""
|
||||||
|
from django.core.exceptions import ObjectDoesNotExist, ValidationError
|
||||||
|
from django.core.urlresolvers import resolve, get_script_prefix, NoReverseMatch
|
||||||
|
from django import forms
|
||||||
|
from django.db.models.fields import BLANK_CHOICE_DASH
|
||||||
|
from django.forms import widgets
|
||||||
|
from django.forms.models import ModelChoiceIterator
|
||||||
|
from django.utils.encoding import smart_text
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
from .fields import Field, WritableField, get_component, is_simple_callable
|
||||||
|
from .reverse import reverse
|
||||||
|
|
||||||
|
import warnings
|
||||||
|
from urllib import parse as urlparse
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
##### Relational fields #####
|
||||||
|
|
||||||
|
|
||||||
|
# Not actually Writable, but subclasses may need to be.
|
||||||
|
class RelatedField(WritableField):
|
||||||
|
"""
|
||||||
|
Base class for related model fields.
|
||||||
|
|
||||||
|
This represents a relationship using the unicode representation of the target.
|
||||||
|
"""
|
||||||
|
widget = widgets.Select
|
||||||
|
many_widget = widgets.SelectMultiple
|
||||||
|
form_field_class = forms.ChoiceField
|
||||||
|
many_form_field_class = forms.MultipleChoiceField
|
||||||
|
null_values = (None, "", "None")
|
||||||
|
|
||||||
|
cache_choices = False
|
||||||
|
empty_label = None
|
||||||
|
read_only = True
|
||||||
|
many = False
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
|
||||||
|
# "null" is to be deprecated in favor of "required"
|
||||||
|
if "null" in kwargs:
|
||||||
|
warnings.warn("The `null` keyword argument is deprecated. "
|
||||||
|
"Use the `required` keyword argument instead.",
|
||||||
|
DeprecationWarning, stacklevel=2)
|
||||||
|
kwargs["required"] = not kwargs.pop("null")
|
||||||
|
|
||||||
|
queryset = kwargs.pop("queryset", None)
|
||||||
|
self.many = kwargs.pop("many", self.many)
|
||||||
|
if self.many:
|
||||||
|
self.widget = self.many_widget
|
||||||
|
self.form_field_class = self.many_form_field_class
|
||||||
|
|
||||||
|
kwargs["read_only"] = kwargs.pop("read_only", self.read_only)
|
||||||
|
super(RelatedField, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
if not self.required:
|
||||||
|
self.empty_label = BLANK_CHOICE_DASH[0][1]
|
||||||
|
|
||||||
|
self.queryset = queryset
|
||||||
|
|
||||||
|
def initialize(self, parent, field_name):
|
||||||
|
super(RelatedField, self).initialize(parent, field_name)
|
||||||
|
if self.queryset is None and not self.read_only:
|
||||||
|
manager = getattr(self.parent.opts.model, self.source or field_name)
|
||||||
|
if hasattr(manager, "related"): # Forward
|
||||||
|
self.queryset = manager.related.model._default_manager.all()
|
||||||
|
else: # Reverse
|
||||||
|
self.queryset = manager.field.rel.to._default_manager.all()
|
||||||
|
|
||||||
|
### We need this stuff to make form choices work...
|
||||||
|
|
||||||
|
def prepare_value(self, obj):
|
||||||
|
return self.to_native(obj)
|
||||||
|
|
||||||
|
def label_from_instance(self, obj):
|
||||||
|
"""
|
||||||
|
Return a readable representation for use with eg. select widgets.
|
||||||
|
"""
|
||||||
|
desc = smart_text(obj)
|
||||||
|
ident = smart_text(self.to_native(obj))
|
||||||
|
if desc == ident:
|
||||||
|
return desc
|
||||||
|
return "%s - %s" % (desc, ident)
|
||||||
|
|
||||||
|
def _get_queryset(self):
|
||||||
|
return self._queryset
|
||||||
|
|
||||||
|
def _set_queryset(self, queryset):
|
||||||
|
self._queryset = queryset
|
||||||
|
self.widget.choices = self.choices
|
||||||
|
|
||||||
|
queryset = property(_get_queryset, _set_queryset)
|
||||||
|
|
||||||
|
def _get_choices(self):
|
||||||
|
# If self._choices is set, then somebody must have manually set
|
||||||
|
# the property self.choices. In this case, just return self._choices.
|
||||||
|
if hasattr(self, "_choices"):
|
||||||
|
return self._choices
|
||||||
|
|
||||||
|
# Otherwise, execute the QuerySet in self.queryset to determine the
|
||||||
|
# choices dynamically. Return a fresh ModelChoiceIterator that has not been
|
||||||
|
# consumed. Note that we"re instantiating a new ModelChoiceIterator *each*
|
||||||
|
# time _get_choices() is called (and, thus, each time self.choices is
|
||||||
|
# accessed) so that we can ensure the QuerySet has not been consumed. This
|
||||||
|
# construct might look complicated but it allows for lazy evaluation of
|
||||||
|
# the queryset.
|
||||||
|
return ModelChoiceIterator(self)
|
||||||
|
|
||||||
|
def _set_choices(self, value):
|
||||||
|
# Setting choices also sets the choices on the widget.
|
||||||
|
# choices can be any iterable, but we call list() on it because
|
||||||
|
# it will be consumed more than once.
|
||||||
|
self._choices = self.widget.choices = list(value)
|
||||||
|
|
||||||
|
choices = property(_get_choices, _set_choices)
|
||||||
|
|
||||||
|
### Default value handling
|
||||||
|
|
||||||
|
def get_default_value(self):
|
||||||
|
default = super(RelatedField, self).get_default_value()
|
||||||
|
if self.many and default is None:
|
||||||
|
return []
|
||||||
|
return default
|
||||||
|
|
||||||
|
### Regular serializer stuff...
|
||||||
|
|
||||||
|
def field_to_native(self, obj, field_name):
|
||||||
|
try:
|
||||||
|
if self.source == "*":
|
||||||
|
return self.to_native(obj)
|
||||||
|
|
||||||
|
source = self.source or field_name
|
||||||
|
value = obj
|
||||||
|
|
||||||
|
for component in source.split("."):
|
||||||
|
if value is None:
|
||||||
|
break
|
||||||
|
value = get_component(value, component)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if self.many:
|
||||||
|
if is_simple_callable(getattr(value, "all", None)):
|
||||||
|
return [self.to_native(item) for item in value.all()]
|
||||||
|
else:
|
||||||
|
# Also support non-queryset iterables.
|
||||||
|
# This allows us to also support plain lists of related items.
|
||||||
|
return [self.to_native(item) for item in value]
|
||||||
|
return self.to_native(value)
|
||||||
|
|
||||||
|
def field_from_native(self, data, files, field_name, into):
|
||||||
|
if self.read_only:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self.many:
|
||||||
|
try:
|
||||||
|
# Form data
|
||||||
|
value = data.getlist(field_name)
|
||||||
|
if value == [""] or value == []:
|
||||||
|
raise KeyError
|
||||||
|
except AttributeError:
|
||||||
|
# Non-form data
|
||||||
|
value = data[field_name]
|
||||||
|
else:
|
||||||
|
value = data[field_name]
|
||||||
|
except KeyError:
|
||||||
|
if self.partial:
|
||||||
|
return
|
||||||
|
value = self.get_default_value()
|
||||||
|
|
||||||
|
if value in self.null_values:
|
||||||
|
if self.required:
|
||||||
|
raise ValidationError(self.error_messages["required"])
|
||||||
|
into[(self.source or field_name)] = None
|
||||||
|
elif self.many:
|
||||||
|
into[(self.source or field_name)] = [self.from_native(item) for item in value]
|
||||||
|
else:
|
||||||
|
into[(self.source or field_name)] = self.from_native(value)
|
||||||
|
|
||||||
|
|
||||||
|
### PrimaryKey relationships
|
||||||
|
|
||||||
|
class PrimaryKeyRelatedField(RelatedField):
|
||||||
|
"""
|
||||||
|
Represents a relationship as a pk value.
|
||||||
|
"""
|
||||||
|
read_only = False
|
||||||
|
|
||||||
|
default_error_messages = {
|
||||||
|
"does_not_exist": _("Invalid pk '%s' - object does not exist."),
|
||||||
|
"incorrect_type": _("Incorrect type. Expected pk value, received %s."),
|
||||||
|
}
|
||||||
|
|
||||||
|
# TODO: Remove these field hacks...
|
||||||
|
def prepare_value(self, obj):
|
||||||
|
return self.to_native(obj.pk)
|
||||||
|
|
||||||
|
def label_from_instance(self, obj):
|
||||||
|
"""
|
||||||
|
Return a readable representation for use with eg. select widgets.
|
||||||
|
"""
|
||||||
|
desc = smart_text(obj)
|
||||||
|
ident = smart_text(self.to_native(obj.pk))
|
||||||
|
if desc == ident:
|
||||||
|
return desc
|
||||||
|
return "%s - %s" % (desc, ident)
|
||||||
|
|
||||||
|
# TODO: Possibly change this to just take `obj`, through prob less performant
|
||||||
|
def to_native(self, pk):
|
||||||
|
return pk
|
||||||
|
|
||||||
|
def from_native(self, data):
|
||||||
|
if self.queryset is None:
|
||||||
|
raise Exception("Writable related fields must include a `queryset` argument")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.queryset.get(pk=data)
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
msg = self.error_messages["does_not_exist"] % smart_text(data)
|
||||||
|
raise ValidationError(msg)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
received = type(data).__name__
|
||||||
|
msg = self.error_messages["incorrect_type"] % received
|
||||||
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
def field_to_native(self, obj, field_name):
|
||||||
|
if self.many:
|
||||||
|
# To-many relationship
|
||||||
|
|
||||||
|
queryset = None
|
||||||
|
if not self.source:
|
||||||
|
# Prefer obj.serializable_value for performance reasons
|
||||||
|
try:
|
||||||
|
queryset = obj.serializable_value(field_name)
|
||||||
|
except AttributeError:
|
||||||
|
pass
|
||||||
|
if queryset is None:
|
||||||
|
# RelatedManager (reverse relationship)
|
||||||
|
source = self.source or field_name
|
||||||
|
queryset = obj
|
||||||
|
for component in source.split("."):
|
||||||
|
if queryset is None:
|
||||||
|
return []
|
||||||
|
queryset = get_component(queryset, component)
|
||||||
|
|
||||||
|
# Forward relationship
|
||||||
|
if is_simple_callable(getattr(queryset, "all", None)):
|
||||||
|
return [self.to_native(item.pk) for item in queryset.all()]
|
||||||
|
else:
|
||||||
|
# Also support non-queryset iterables.
|
||||||
|
# This allows us to also support plain lists of related items.
|
||||||
|
return [self.to_native(item.pk) for item in queryset]
|
||||||
|
|
||||||
|
# To-one relationship
|
||||||
|
try:
|
||||||
|
# Prefer obj.serializable_value for performance reasons
|
||||||
|
pk = obj.serializable_value(self.source or field_name)
|
||||||
|
except AttributeError:
|
||||||
|
# RelatedObject (reverse relationship)
|
||||||
|
try:
|
||||||
|
pk = getattr(obj, self.source or field_name).pk
|
||||||
|
except (ObjectDoesNotExist, AttributeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Forward relationship
|
||||||
|
return self.to_native(pk)
|
||||||
|
|
||||||
|
|
||||||
|
### Slug relationships
|
||||||
|
|
||||||
|
|
||||||
|
class SlugRelatedField(RelatedField):
|
||||||
|
"""
|
||||||
|
Represents a relationship using a unique field on the target.
|
||||||
|
"""
|
||||||
|
read_only = False
|
||||||
|
|
||||||
|
default_error_messages = {
|
||||||
|
"does_not_exist": _("Object with %s=%s does not exist."),
|
||||||
|
"invalid": _("Invalid value."),
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
self.slug_field = kwargs.pop("slug_field", None)
|
||||||
|
assert self.slug_field, "slug_field is required"
|
||||||
|
super(SlugRelatedField, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def to_native(self, obj):
|
||||||
|
return getattr(obj, self.slug_field)
|
||||||
|
|
||||||
|
def from_native(self, data):
|
||||||
|
if self.queryset is None:
|
||||||
|
raise Exception("Writable related fields must include a `queryset` argument")
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.queryset.get(**{self.slug_field: data})
|
||||||
|
except ObjectDoesNotExist:
|
||||||
|
raise ValidationError(self.error_messages["does_not_exist"] %
|
||||||
|
(self.slug_field, smart_text(data)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
msg = self.error_messages["invalid"]
|
||||||
|
raise ValidationError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
### Hyperlinked relationships
|
||||||
|
|
||||||
|
class HyperlinkedRelatedField(RelatedField):
|
||||||
|
"""
|
||||||
|
Represents a relationship using hyperlinking.
|
||||||
|
"""
|
||||||
|
read_only = False
|
||||||
|
lookup_field = "pk"
|
||||||
|
|
||||||
|
default_error_messages = {
|
||||||
|
"no_match": _("Invalid hyperlink - No URL match"),
|
||||||
|
"incorrect_match": _("Invalid hyperlink - Incorrect URL match"),
|
||||||
|
"configuration_error": _("Invalid hyperlink due to configuration error"),
|
||||||
|
"does_not_exist": _("Invalid hyperlink - object does not exist."),
|
||||||
|
"incorrect_type": _("Incorrect type. Expected url string, received %s."),
|
||||||
|
}
|
||||||
|
|
||||||
|
# These are all pending deprecation
|
||||||
|
pk_url_kwarg = "pk"
|
||||||
|
slug_field = "slug"
|
||||||
|
slug_url_kwarg = None # Defaults to same as `slug_field` unless overridden
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
self.view_name = kwargs.pop("view_name")
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError("Hyperlinked field requires \"view_name\" kwarg")
|
||||||
|
|
||||||
|
self.lookup_field = kwargs.pop("lookup_field", self.lookup_field)
|
||||||
|
self.format = kwargs.pop("format", None)
|
||||||
|
|
||||||
|
# These are pending deprecation
|
||||||
|
if "pk_url_kwarg" in kwargs:
|
||||||
|
msg = "pk_url_kwarg is pending deprecation. Use lookup_field instead."
|
||||||
|
warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
|
||||||
|
if "slug_url_kwarg" in kwargs:
|
||||||
|
msg = "slug_url_kwarg is pending deprecation. Use lookup_field instead."
|
||||||
|
warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
|
||||||
|
if "slug_field" in kwargs:
|
||||||
|
msg = "slug_field is pending deprecation. Use lookup_field instead."
|
||||||
|
warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
|
||||||
|
|
||||||
|
self.pk_url_kwarg = kwargs.pop("pk_url_kwarg", self.pk_url_kwarg)
|
||||||
|
self.slug_field = kwargs.pop("slug_field", self.slug_field)
|
||||||
|
default_slug_kwarg = self.slug_url_kwarg or self.slug_field
|
||||||
|
self.slug_url_kwarg = kwargs.pop("slug_url_kwarg", default_slug_kwarg)
|
||||||
|
|
||||||
|
super(HyperlinkedRelatedField, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def get_url(self, obj, view_name, request, format):
|
||||||
|
"""
|
||||||
|
Given an object, return the URL that hyperlinks to the object.
|
||||||
|
|
||||||
|
May raise a `NoReverseMatch` if the `view_name` and `lookup_field`
|
||||||
|
attributes are not configured to correctly match the URL conf.
|
||||||
|
"""
|
||||||
|
lookup_field = getattr(obj, self.lookup_field)
|
||||||
|
kwargs = {self.lookup_field: lookup_field}
|
||||||
|
try:
|
||||||
|
return reverse(view_name, kwargs=kwargs, request=request, format=format)
|
||||||
|
except NoReverseMatch:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.pk_url_kwarg != "pk":
|
||||||
|
# Only try pk if it has been explicitly set.
|
||||||
|
# Otherwise, the default `lookup_field = "pk"` has us covered.
|
||||||
|
pk = obj.pk
|
||||||
|
kwargs = {self.pk_url_kwarg: pk}
|
||||||
|
try:
|
||||||
|
return reverse(view_name, kwargs=kwargs, request=request, format=format)
|
||||||
|
except NoReverseMatch:
|
||||||
|
pass
|
||||||
|
|
||||||
|
slug = getattr(obj, self.slug_field, None)
|
||||||
|
if slug is not None:
|
||||||
|
# Only try slug if it corresponds to an attribute on the object.
|
||||||
|
kwargs = {self.slug_url_kwarg: slug}
|
||||||
|
try:
|
||||||
|
ret = reverse(view_name, kwargs=kwargs, request=request, format=format)
|
||||||
|
if self.slug_field == "slug" and self.slug_url_kwarg == "slug":
|
||||||
|
# If the lookup succeeds using the default slug params,
|
||||||
|
# then `slug_field` is being used implicitly, and we
|
||||||
|
# we need to warn about the pending deprecation.
|
||||||
|
msg = "Implicit slug field hyperlinked fields are pending deprecation." \
|
||||||
|
"You should set `lookup_field=slug` on the HyperlinkedRelatedField."
|
||||||
|
warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
|
||||||
|
return ret
|
||||||
|
except NoReverseMatch:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise NoReverseMatch()
|
||||||
|
|
||||||
|
def get_object(self, queryset, view_name, view_args, view_kwargs):
|
||||||
|
"""
|
||||||
|
Return the object corresponding to a matched URL.
|
||||||
|
|
||||||
|
Takes the matched URL conf arguments, and the queryset, and should
|
||||||
|
return an object instance, or raise an `ObjectDoesNotExist` exception.
|
||||||
|
"""
|
||||||
|
lookup = view_kwargs.get(self.lookup_field, None)
|
||||||
|
pk = view_kwargs.get(self.pk_url_kwarg, None)
|
||||||
|
slug = view_kwargs.get(self.slug_url_kwarg, None)
|
||||||
|
|
||||||
|
if lookup is not None:
|
||||||
|
filter_kwargs = {self.lookup_field: lookup}
|
||||||
|
elif pk is not None:
|
||||||
|
filter_kwargs = {"pk": pk}
|
||||||
|
elif slug is not None:
|
||||||
|
filter_kwargs = {self.slug_field: slug}
|
||||||
|
else:
|
||||||
|
raise ObjectDoesNotExist()
|
||||||
|
|
||||||
|
return queryset.get(**filter_kwargs)
|
||||||
|
|
||||||
|
def to_native(self, obj):
|
||||||
|
view_name = self.view_name
|
||||||
|
request = self.context.get("request", None)
|
||||||
|
format = self.format or self.context.get("format", None)
|
||||||
|
|
||||||
|
if request is None:
|
||||||
|
msg = (
|
||||||
|
"Using `HyperlinkedRelatedField` without including the request "
|
||||||
|
"in the serializer context is deprecated. "
|
||||||
|
"Add `context={'request': request}` when instantiating "
|
||||||
|
"the serializer."
|
||||||
|
)
|
||||||
|
warnings.warn(msg, DeprecationWarning, stacklevel=4)
|
||||||
|
|
||||||
|
# If the object has not yet been saved then we cannot hyperlink to it.
|
||||||
|
if getattr(obj, "pk", None) is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Return the hyperlink, or error if incorrectly configured.
|
||||||
|
try:
|
||||||
|
return self.get_url(obj, view_name, request, format)
|
||||||
|
except NoReverseMatch:
|
||||||
|
msg = (
|
||||||
|
"Could not resolve URL for hyperlinked relationship using "
|
||||||
|
"view name '%s'. You may have failed to include the related "
|
||||||
|
"model in your API, or incorrectly configured the "
|
||||||
|
"`lookup_field` attribute on this field."
|
||||||
|
)
|
||||||
|
raise Exception(msg % view_name)
|
||||||
|
|
||||||
|
def from_native(self, value):
|
||||||
|
# Convert URL -> model instance pk
|
||||||
|
# TODO: Use values_list
|
||||||
|
queryset = self.queryset
|
||||||
|
if queryset is None:
|
||||||
|
raise Exception("Writable related fields must include a `queryset` argument")
|
||||||
|
|
||||||
|
try:
|
||||||
|
http_prefix = value.startswith(("http:", "https:"))
|
||||||
|
except AttributeError:
|
||||||
|
msg = self.error_messages["incorrect_type"]
|
||||||
|
raise ValidationError(msg % type(value).__name__)
|
||||||
|
|
||||||
|
if http_prefix:
|
||||||
|
# If needed convert absolute URLs to relative path
|
||||||
|
value = urlparse.urlparse(value).path
|
||||||
|
prefix = get_script_prefix()
|
||||||
|
if value.startswith(prefix):
|
||||||
|
value = "/" + value[len(prefix):]
|
||||||
|
|
||||||
|
try:
|
||||||
|
match = resolve(value)
|
||||||
|
except Exception:
|
||||||
|
raise ValidationError(self.error_messages["no_match"])
|
||||||
|
|
||||||
|
if match.view_name != self.view_name:
|
||||||
|
raise ValidationError(self.error_messages["incorrect_match"])
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.get_object(queryset, match.view_name,
|
||||||
|
match.args, match.kwargs)
|
||||||
|
except (ObjectDoesNotExist, TypeError, ValueError):
|
||||||
|
raise ValidationError(self.error_messages["does_not_exist"])
|
||||||
|
|
||||||
|
|
||||||
|
class HyperlinkedIdentityField(Field):
|
||||||
|
"""
|
||||||
|
Represents the instance, or a property on the instance, using hyperlinking.
|
||||||
|
"""
|
||||||
|
lookup_field = "pk"
|
||||||
|
read_only = True
|
||||||
|
|
||||||
|
# These are all pending deprecation
|
||||||
|
pk_url_kwarg = "pk"
|
||||||
|
slug_field = "slug"
|
||||||
|
slug_url_kwarg = None # Defaults to same as `slug_field` unless overridden
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
self.view_name = kwargs.pop("view_name")
|
||||||
|
except KeyError:
|
||||||
|
msg = "HyperlinkedIdentityField requires \"view_name\" argument"
|
||||||
|
raise ValueError(msg)
|
||||||
|
|
||||||
|
self.format = kwargs.pop("format", None)
|
||||||
|
lookup_field = kwargs.pop("lookup_field", None)
|
||||||
|
self.lookup_field = lookup_field or self.lookup_field
|
||||||
|
|
||||||
|
# These are pending deprecation
|
||||||
|
if "pk_url_kwarg" in kwargs:
|
||||||
|
msg = "pk_url_kwarg is pending deprecation. Use lookup_field instead."
|
||||||
|
warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
|
||||||
|
if "slug_url_kwarg" in kwargs:
|
||||||
|
msg = "slug_url_kwarg is pending deprecation. Use lookup_field instead."
|
||||||
|
warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
|
||||||
|
if "slug_field" in kwargs:
|
||||||
|
msg = "slug_field is pending deprecation. Use lookup_field instead."
|
||||||
|
warnings.warn(msg, PendingDeprecationWarning, stacklevel=2)
|
||||||
|
|
||||||
|
self.slug_field = kwargs.pop("slug_field", self.slug_field)
|
||||||
|
default_slug_kwarg = self.slug_url_kwarg or self.slug_field
|
||||||
|
self.pk_url_kwarg = kwargs.pop("pk_url_kwarg", self.pk_url_kwarg)
|
||||||
|
self.slug_url_kwarg = kwargs.pop("slug_url_kwarg", default_slug_kwarg)
|
||||||
|
|
||||||
|
super(HyperlinkedIdentityField, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def field_to_native(self, obj, field_name):
|
||||||
|
request = self.context.get("request", None)
|
||||||
|
format = self.context.get("format", None)
|
||||||
|
view_name = self.view_name
|
||||||
|
|
||||||
|
if request is None:
|
||||||
|
warnings.warn("Using `HyperlinkedIdentityField` without including the "
|
||||||
|
"request in the serializer context is deprecated. "
|
||||||
|
"Add `context={'request': request}` when instantiating the serializer.",
|
||||||
|
DeprecationWarning, stacklevel=4)
|
||||||
|
|
||||||
|
# By default use whatever format is given for the current context
|
||||||
|
# unless the target is a different type to the source.
|
||||||
|
#
|
||||||
|
# Eg. Consider a HyperlinkedIdentityField pointing from a json
|
||||||
|
# representation to an html property of that representation...
|
||||||
|
#
|
||||||
|
# "/snippets/1/" should link to "/snippets/1/highlight/"
|
||||||
|
# ...but...
|
||||||
|
# "/snippets/1/.json" should link to "/snippets/1/highlight/.html"
|
||||||
|
if format and self.format and self.format != format:
|
||||||
|
format = self.format
|
||||||
|
|
||||||
|
# Return the hyperlink, or error if incorrectly configured.
|
||||||
|
try:
|
||||||
|
return self.get_url(obj, view_name, request, format)
|
||||||
|
except NoReverseMatch:
|
||||||
|
msg = (
|
||||||
|
"Could not resolve URL for hyperlinked relationship using "
|
||||||
|
"view name '%s'. You may have failed to include the related "
|
||||||
|
"model in your API, or incorrectly configured the "
|
||||||
|
"`lookup_field` attribute on this field."
|
||||||
|
)
|
||||||
|
raise Exception(msg % view_name)
|
||||||
|
|
||||||
|
def get_url(self, obj, view_name, request, format):
|
||||||
|
"""
|
||||||
|
Given an object, return the URL that hyperlinks to the object.
|
||||||
|
|
||||||
|
May raise a `NoReverseMatch` if the `view_name` and `lookup_field`
|
||||||
|
attributes are not configured to correctly match the URL conf.
|
||||||
|
"""
|
||||||
|
lookup_field = getattr(obj, self.lookup_field, None)
|
||||||
|
kwargs = {self.lookup_field: lookup_field}
|
||||||
|
|
||||||
|
# Handle unsaved object case
|
||||||
|
if lookup_field is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return reverse(view_name, kwargs=kwargs, request=request, format=format)
|
||||||
|
except NoReverseMatch:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if self.pk_url_kwarg != "pk":
|
||||||
|
# Only try pk lookup if it has been explicitly set.
|
||||||
|
# Otherwise, the default `lookup_field = "pk"` has us covered.
|
||||||
|
kwargs = {self.pk_url_kwarg: obj.pk}
|
||||||
|
try:
|
||||||
|
return reverse(view_name, kwargs=kwargs, request=request, format=format)
|
||||||
|
except NoReverseMatch:
|
||||||
|
pass
|
||||||
|
|
||||||
|
slug = getattr(obj, self.slug_field, None)
|
||||||
|
if slug:
|
||||||
|
# Only use slug lookup if a slug field exists on the model
|
||||||
|
kwargs = {self.slug_url_kwarg: slug}
|
||||||
|
try:
|
||||||
|
return reverse(view_name, kwargs=kwargs, request=request, format=format)
|
||||||
|
except NoReverseMatch:
|
||||||
|
pass
|
||||||
|
|
||||||
|
raise NoReverseMatch()
|
|
@ -0,0 +1,613 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
"""
|
||||||
|
Renderers are used to serialize a response into specific media types.
|
||||||
|
|
||||||
|
They give us a generic way of being able to handle various media types
|
||||||
|
on the response, such as JSON encoded data or HTML output.
|
||||||
|
|
||||||
|
REST framework also provides an HTML renderer the renders the browsable API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import django
|
||||||
|
from django import forms
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from django.http.multipartparser import parse_header
|
||||||
|
from django.template import RequestContext, loader, Template
|
||||||
|
from django.test.client import encode_multipart
|
||||||
|
from django.utils import six
|
||||||
|
from django.utils.encoding import smart_text
|
||||||
|
from django.utils.six import StringIO
|
||||||
|
from django.utils.xmlutils import SimplerXMLGenerator
|
||||||
|
|
||||||
|
from taiga.base import exceptions, status
|
||||||
|
from taiga.base.exceptions import ParseError
|
||||||
|
|
||||||
|
from . import VERSION
|
||||||
|
from .request import is_form_media_type, override_method
|
||||||
|
from .settings import api_settings
|
||||||
|
from .utils import encoders
|
||||||
|
from .utils.breadcrumbs import get_breadcrumbs
|
||||||
|
|
||||||
|
import json
|
||||||
|
import copy
|
||||||
|
|
||||||
|
|
||||||
|
class BaseRenderer(object):
|
||||||
|
"""
|
||||||
|
All renderers should extend this class, setting the `media_type`
|
||||||
|
and `format` attributes, and override the `.render()` method.
|
||||||
|
"""
|
||||||
|
|
||||||
|
media_type = None
|
||||||
|
format = None
|
||||||
|
charset = "utf-8"
|
||||||
|
render_style = "text"
|
||||||
|
|
||||||
|
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||||
|
raise NotImplemented("Renderer class requires .render() to be implemented")
|
||||||
|
|
||||||
|
|
||||||
|
class JSONRenderer(BaseRenderer):
|
||||||
|
"""
|
||||||
|
Renderer which serializes to JSON.
|
||||||
|
Applies JSON's backslash-u character escaping for non-ascii characters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
media_type = "application/json"
|
||||||
|
format = "json"
|
||||||
|
encoder_class = encoders.JSONEncoder
|
||||||
|
ensure_ascii = True
|
||||||
|
charset = None
|
||||||
|
# JSON is a binary encoding, that can be encoded as utf-8, utf-16 or utf-32.
|
||||||
|
# See: http://www.ietf.org/rfc/rfc4627.txt
|
||||||
|
# Also: http://lucumr.pocoo.org/2013/7/19/application-mimetypes-and-encodings/
|
||||||
|
|
||||||
|
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||||
|
"""
|
||||||
|
Render `data` into JSON.
|
||||||
|
"""
|
||||||
|
if data is None:
|
||||||
|
return bytes()
|
||||||
|
|
||||||
|
# If "indent" is provided in the context, then pretty print the result.
|
||||||
|
# E.g. If we"re being called by the BrowsableAPIRenderer.
|
||||||
|
renderer_context = renderer_context or {}
|
||||||
|
indent = renderer_context.get("indent", None)
|
||||||
|
|
||||||
|
if accepted_media_type:
|
||||||
|
# If the media type looks like "application/json; indent=4",
|
||||||
|
# then pretty print the result.
|
||||||
|
base_media_type, params = parse_header(accepted_media_type.encode("ascii"))
|
||||||
|
indent = params.get("indent", indent)
|
||||||
|
try:
|
||||||
|
indent = max(min(int(indent), 8), 0)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
indent = None
|
||||||
|
|
||||||
|
ret = json.dumps(data, cls=self.encoder_class,
|
||||||
|
indent=indent, ensure_ascii=self.ensure_ascii)
|
||||||
|
|
||||||
|
# On python 2.x json.dumps() returns bytestrings if ensure_ascii=True,
|
||||||
|
# but if ensure_ascii=False, the return type is underspecified,
|
||||||
|
# and may (or may not) be unicode.
|
||||||
|
# On python 3.x json.dumps() returns unicode strings.
|
||||||
|
if isinstance(ret, six.text_type):
|
||||||
|
return bytes(ret.encode("utf-8"))
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class UnicodeJSONRenderer(JSONRenderer):
|
||||||
|
ensure_ascii = False
|
||||||
|
"""
|
||||||
|
Renderer which serializes to JSON.
|
||||||
|
Does *not* apply JSON's character escaping for non-ascii characters.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class JSONPRenderer(JSONRenderer):
|
||||||
|
"""
|
||||||
|
Renderer which serializes to json,
|
||||||
|
wrapping the json output in a callback function.
|
||||||
|
"""
|
||||||
|
|
||||||
|
media_type = "application/javascript"
|
||||||
|
format = "jsonp"
|
||||||
|
callback_parameter = "callback"
|
||||||
|
default_callback = "callback"
|
||||||
|
charset = "utf-8"
|
||||||
|
|
||||||
|
def get_callback(self, renderer_context):
|
||||||
|
"""
|
||||||
|
Determine the name of the callback to wrap around the json output.
|
||||||
|
"""
|
||||||
|
request = renderer_context.get("request", None)
|
||||||
|
params = request and request.QUERY_PARAMS or {}
|
||||||
|
return params.get(self.callback_parameter, self.default_callback)
|
||||||
|
|
||||||
|
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||||
|
"""
|
||||||
|
Renders into jsonp, wrapping the json output in a callback function.
|
||||||
|
|
||||||
|
Clients may set the callback function name using a query parameter
|
||||||
|
on the URL, for example: ?callback=exampleCallbackName
|
||||||
|
"""
|
||||||
|
renderer_context = renderer_context or {}
|
||||||
|
callback = self.get_callback(renderer_context)
|
||||||
|
json = super(JSONPRenderer, self).render(data, accepted_media_type,
|
||||||
|
renderer_context)
|
||||||
|
return callback.encode(self.charset) + b"(" + json + b");"
|
||||||
|
|
||||||
|
|
||||||
|
class XMLRenderer(BaseRenderer):
|
||||||
|
"""
|
||||||
|
Renderer which serializes to XML.
|
||||||
|
"""
|
||||||
|
|
||||||
|
media_type = "application/xml"
|
||||||
|
format = "xml"
|
||||||
|
charset = "utf-8"
|
||||||
|
|
||||||
|
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||||
|
"""
|
||||||
|
Renders `data` into serialized XML.
|
||||||
|
"""
|
||||||
|
if data is None:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
stream = StringIO()
|
||||||
|
|
||||||
|
xml = SimplerXMLGenerator(stream, self.charset)
|
||||||
|
xml.startDocument()
|
||||||
|
xml.startElement("root", {})
|
||||||
|
|
||||||
|
self._to_xml(xml, data)
|
||||||
|
|
||||||
|
xml.endElement("root")
|
||||||
|
xml.endDocument()
|
||||||
|
return stream.getvalue()
|
||||||
|
|
||||||
|
def _to_xml(self, xml, data):
|
||||||
|
if isinstance(data, (list, tuple)):
|
||||||
|
for item in data:
|
||||||
|
xml.startElement("list-item", {})
|
||||||
|
self._to_xml(xml, item)
|
||||||
|
xml.endElement("list-item")
|
||||||
|
|
||||||
|
elif isinstance(data, dict):
|
||||||
|
for key, value in six.iteritems(data):
|
||||||
|
xml.startElement(key, {})
|
||||||
|
self._to_xml(xml, value)
|
||||||
|
xml.endElement(key)
|
||||||
|
|
||||||
|
elif data is None:
|
||||||
|
# Don't output any value
|
||||||
|
pass
|
||||||
|
|
||||||
|
else:
|
||||||
|
xml.characters(smart_text(data))
|
||||||
|
|
||||||
|
|
||||||
|
class TemplateHTMLRenderer(BaseRenderer):
|
||||||
|
"""
|
||||||
|
An HTML renderer for use with templates.
|
||||||
|
|
||||||
|
The data supplied to the Response object should be a dictionary that will
|
||||||
|
be used as context for the template.
|
||||||
|
|
||||||
|
The template name is determined by (in order of preference):
|
||||||
|
|
||||||
|
1. An explicit `.template_name` attribute set on the response.
|
||||||
|
2. An explicit `.template_name` attribute set on this class.
|
||||||
|
3. The return result of calling `view.get_template_names()`.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
data = {"users": User.objects.all()}
|
||||||
|
return Response(data, template_name="users.html")
|
||||||
|
|
||||||
|
For pre-rendered HTML, see StaticHTMLRenderer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
media_type = "text/html"
|
||||||
|
format = "html"
|
||||||
|
template_name = None
|
||||||
|
exception_template_names = [
|
||||||
|
"%(status_code)s.html",
|
||||||
|
"api_exception.html"
|
||||||
|
]
|
||||||
|
charset = "utf-8"
|
||||||
|
|
||||||
|
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||||
|
"""
|
||||||
|
Renders data to HTML, using Django's standard template rendering.
|
||||||
|
|
||||||
|
The template name is determined by (in order of preference):
|
||||||
|
|
||||||
|
1. An explicit .template_name set on the response.
|
||||||
|
2. An explicit .template_name set on this class.
|
||||||
|
3. The return result of calling view.get_template_names().
|
||||||
|
"""
|
||||||
|
renderer_context = renderer_context or {}
|
||||||
|
view = renderer_context["view"]
|
||||||
|
request = renderer_context["request"]
|
||||||
|
response = renderer_context["response"]
|
||||||
|
|
||||||
|
if response.exception:
|
||||||
|
template = self.get_exception_template(response)
|
||||||
|
else:
|
||||||
|
template_names = self.get_template_names(response, view)
|
||||||
|
template = self.resolve_template(template_names)
|
||||||
|
|
||||||
|
context = self.resolve_context(data, request, response)
|
||||||
|
return template.render(context)
|
||||||
|
|
||||||
|
def resolve_template(self, template_names):
|
||||||
|
return loader.select_template(template_names)
|
||||||
|
|
||||||
|
def resolve_context(self, data, request, response):
|
||||||
|
if response.exception:
|
||||||
|
data["status_code"] = response.status_code
|
||||||
|
return RequestContext(request, data)
|
||||||
|
|
||||||
|
def get_template_names(self, response, view):
|
||||||
|
if response.template_name:
|
||||||
|
return [response.template_name]
|
||||||
|
elif self.template_name:
|
||||||
|
return [self.template_name]
|
||||||
|
elif hasattr(view, "get_template_names"):
|
||||||
|
return view.get_template_names()
|
||||||
|
elif hasattr(view, "template_name"):
|
||||||
|
return [view.template_name]
|
||||||
|
raise ImproperlyConfigured("Returned a template response with no `template_name` attribute set on either the view or response")
|
||||||
|
|
||||||
|
def get_exception_template(self, response):
|
||||||
|
template_names = [name % {"status_code": response.status_code}
|
||||||
|
for name in self.exception_template_names]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to find an appropriate error template
|
||||||
|
return self.resolve_template(template_names)
|
||||||
|
except Exception:
|
||||||
|
# Fall back to using eg "404 Not Found"
|
||||||
|
return Template("%d %s" % (response.status_code,
|
||||||
|
response.status_text.title()))
|
||||||
|
|
||||||
|
|
||||||
|
# Note, subclass TemplateHTMLRenderer simply for the exception behavior
|
||||||
|
class StaticHTMLRenderer(TemplateHTMLRenderer):
|
||||||
|
"""
|
||||||
|
An HTML renderer class that simply returns pre-rendered HTML.
|
||||||
|
|
||||||
|
The data supplied to the Response object should be a string representing
|
||||||
|
the pre-rendered HTML content.
|
||||||
|
|
||||||
|
For example:
|
||||||
|
data = "<html><body>example</body></html>"
|
||||||
|
return Response(data)
|
||||||
|
|
||||||
|
For template rendered HTML, see TemplateHTMLRenderer.
|
||||||
|
"""
|
||||||
|
media_type = "text/html"
|
||||||
|
format = "html"
|
||||||
|
charset = "utf-8"
|
||||||
|
|
||||||
|
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||||
|
renderer_context = renderer_context or {}
|
||||||
|
response = renderer_context["response"]
|
||||||
|
|
||||||
|
if response and response.exception:
|
||||||
|
request = renderer_context["request"]
|
||||||
|
template = self.get_exception_template(response)
|
||||||
|
context = self.resolve_context(data, request, response)
|
||||||
|
return template.render(context)
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLFormRenderer(BaseRenderer):
|
||||||
|
"""
|
||||||
|
Renderers serializer data into an HTML form.
|
||||||
|
|
||||||
|
If the serializer was instantiated without an object then this will
|
||||||
|
return an HTML form not bound to any object,
|
||||||
|
otherwise it will return an HTML form with the appropriate initial data
|
||||||
|
populated from the object.
|
||||||
|
|
||||||
|
Note that rendering of field and form errors is not currently supported.
|
||||||
|
"""
|
||||||
|
media_type = "text/html"
|
||||||
|
format = "form"
|
||||||
|
template = "api/form.html"
|
||||||
|
charset = "utf-8"
|
||||||
|
|
||||||
|
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||||
|
"""
|
||||||
|
Render serializer data and return an HTML form, as a string.
|
||||||
|
"""
|
||||||
|
renderer_context = renderer_context or {}
|
||||||
|
request = renderer_context["request"]
|
||||||
|
|
||||||
|
template = loader.get_template(self.template)
|
||||||
|
context = RequestContext(request, {"form": data})
|
||||||
|
return template.render(context)
|
||||||
|
|
||||||
|
|
||||||
|
class BrowsableAPIRenderer(BaseRenderer):
|
||||||
|
"""
|
||||||
|
HTML renderer used to self-document the API.
|
||||||
|
"""
|
||||||
|
media_type = "text/html"
|
||||||
|
format = "api"
|
||||||
|
template = "api/api.html"
|
||||||
|
charset = "utf-8"
|
||||||
|
form_renderer_class = HTMLFormRenderer
|
||||||
|
|
||||||
|
def get_default_renderer(self, view):
|
||||||
|
"""
|
||||||
|
Return an instance of the first valid renderer.
|
||||||
|
(Don't use another documenting renderer.)
|
||||||
|
"""
|
||||||
|
renderers = [renderer for renderer in view.renderer_classes
|
||||||
|
if not issubclass(renderer, BrowsableAPIRenderer)]
|
||||||
|
non_template_renderers = [renderer for renderer in renderers
|
||||||
|
if not hasattr(renderer, "get_template_names")]
|
||||||
|
|
||||||
|
if not renderers:
|
||||||
|
return None
|
||||||
|
elif non_template_renderers:
|
||||||
|
return non_template_renderers[0]()
|
||||||
|
return renderers[0]()
|
||||||
|
|
||||||
|
def get_content(self, renderer, data,
|
||||||
|
accepted_media_type, renderer_context):
|
||||||
|
"""
|
||||||
|
Get the content as if it had been rendered by the default
|
||||||
|
non-documenting renderer.
|
||||||
|
"""
|
||||||
|
if not renderer:
|
||||||
|
return "[No renderers were found]"
|
||||||
|
|
||||||
|
renderer_context["indent"] = 4
|
||||||
|
content = renderer.render(data, accepted_media_type, renderer_context)
|
||||||
|
|
||||||
|
render_style = getattr(renderer, "render_style", "text")
|
||||||
|
assert render_style in ["text", "binary"], 'Expected .render_style "text" or "binary", ' \
|
||||||
|
'but got "%s"' % render_style
|
||||||
|
if render_style == "binary":
|
||||||
|
return "[%d bytes of binary content]" % len(content)
|
||||||
|
|
||||||
|
return content
|
||||||
|
|
||||||
|
def show_form_for_method(self, view, method, request, obj):
|
||||||
|
"""
|
||||||
|
Returns True if a form should be shown for this method.
|
||||||
|
"""
|
||||||
|
if not method in view.allowed_methods:
|
||||||
|
return # Not a valid method
|
||||||
|
|
||||||
|
if not api_settings.FORM_METHOD_OVERRIDE:
|
||||||
|
return # Cannot use form overloading
|
||||||
|
|
||||||
|
try:
|
||||||
|
view.check_permissions(request)
|
||||||
|
if obj is not None:
|
||||||
|
view.check_object_permissions(request, obj)
|
||||||
|
except exceptions.APIException:
|
||||||
|
return False # Doesn't have permissions
|
||||||
|
return True
|
||||||
|
|
||||||
|
def get_rendered_html_form(self, view, method, request):
|
||||||
|
"""
|
||||||
|
Return a string representing a rendered HTML form, possibly bound to
|
||||||
|
either the input or output data.
|
||||||
|
|
||||||
|
In the absence of the View having an associated form then return None.
|
||||||
|
"""
|
||||||
|
if request.method == method:
|
||||||
|
try:
|
||||||
|
data = request.DATA
|
||||||
|
files = request.FILES
|
||||||
|
except ParseError:
|
||||||
|
data = None
|
||||||
|
files = None
|
||||||
|
else:
|
||||||
|
data = None
|
||||||
|
files = None
|
||||||
|
|
||||||
|
with override_method(view, request, method) as request:
|
||||||
|
obj = getattr(view, "object", None)
|
||||||
|
if not self.show_form_for_method(view, method, request, obj):
|
||||||
|
return
|
||||||
|
|
||||||
|
if method in ("DELETE", "OPTIONS"):
|
||||||
|
return True # Don't actually need to return a form
|
||||||
|
|
||||||
|
if (not getattr(view, "get_serializer", None)
|
||||||
|
or not any(is_form_media_type(parser.media_type) for parser in view.parser_classes)):
|
||||||
|
return
|
||||||
|
|
||||||
|
serializer = view.get_serializer(instance=obj, data=data, files=files)
|
||||||
|
serializer.is_valid()
|
||||||
|
data = serializer.data
|
||||||
|
|
||||||
|
form_renderer = self.form_renderer_class()
|
||||||
|
return form_renderer.render(data, self.accepted_media_type, self.renderer_context)
|
||||||
|
|
||||||
|
def get_raw_data_form(self, view, method, request):
|
||||||
|
"""
|
||||||
|
Returns a form that allows for arbitrary content types to be tunneled
|
||||||
|
via standard HTML forms.
|
||||||
|
(Which are typically application/x-www-form-urlencoded)
|
||||||
|
"""
|
||||||
|
with override_method(view, request, method) as request:
|
||||||
|
# If we"re not using content overloading there's no point in
|
||||||
|
# supplying a generic form, as the view won't treat the form"s
|
||||||
|
# value as the content of the request.
|
||||||
|
if not (api_settings.FORM_CONTENT_OVERRIDE
|
||||||
|
and api_settings.FORM_CONTENTTYPE_OVERRIDE):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
obj = getattr(view, "object", None)
|
||||||
|
if not self.show_form_for_method(view, method, request, obj):
|
||||||
|
return
|
||||||
|
|
||||||
|
# If possible, serialize the initial content for the generic form
|
||||||
|
default_parser = view.parser_classes[0]
|
||||||
|
renderer_class = getattr(default_parser, "renderer_class", None)
|
||||||
|
if (hasattr(view, "get_serializer") and renderer_class):
|
||||||
|
# View has a serializer defined and parser class has a
|
||||||
|
# corresponding renderer that can be used to render the data.
|
||||||
|
|
||||||
|
# Get a read-only version of the serializer
|
||||||
|
serializer = view.get_serializer(instance=obj)
|
||||||
|
if obj is None:
|
||||||
|
for name, field in serializer.fields.items():
|
||||||
|
if getattr(field, "read_only", None):
|
||||||
|
del serializer.fields[name]
|
||||||
|
|
||||||
|
# Render the raw data content
|
||||||
|
renderer = renderer_class()
|
||||||
|
accepted = self.accepted_media_type
|
||||||
|
context = self.renderer_context.copy()
|
||||||
|
context["indent"] = 4
|
||||||
|
content = renderer.render(serializer.data, accepted, context)
|
||||||
|
else:
|
||||||
|
content = None
|
||||||
|
|
||||||
|
# Generate a generic form that includes a content type field,
|
||||||
|
# and a content field.
|
||||||
|
content_type_field = api_settings.FORM_CONTENTTYPE_OVERRIDE
|
||||||
|
content_field = api_settings.FORM_CONTENT_OVERRIDE
|
||||||
|
|
||||||
|
media_types = [parser.media_type for parser in view.parser_classes]
|
||||||
|
choices = [(media_type, media_type) for media_type in media_types]
|
||||||
|
initial = media_types[0]
|
||||||
|
|
||||||
|
# NB. http://jacobian.org/writing/dynamic-form-generation/
|
||||||
|
class GenericContentForm(forms.Form):
|
||||||
|
def __init__(self):
|
||||||
|
super(GenericContentForm, self).__init__()
|
||||||
|
|
||||||
|
self.fields[content_type_field] = forms.ChoiceField(
|
||||||
|
label="Media type",
|
||||||
|
choices=choices,
|
||||||
|
initial=initial
|
||||||
|
)
|
||||||
|
self.fields[content_field] = forms.CharField(
|
||||||
|
label="Content",
|
||||||
|
widget=forms.Textarea,
|
||||||
|
initial=content
|
||||||
|
)
|
||||||
|
|
||||||
|
return GenericContentForm()
|
||||||
|
|
||||||
|
def get_name(self, view):
|
||||||
|
return view.get_view_name()
|
||||||
|
|
||||||
|
def get_description(self, view):
|
||||||
|
return view.get_view_description(html=True)
|
||||||
|
|
||||||
|
def get_breadcrumbs(self, request):
|
||||||
|
return get_breadcrumbs(request.path)
|
||||||
|
|
||||||
|
def get_context(self, data, accepted_media_type, renderer_context):
|
||||||
|
"""
|
||||||
|
Returns the context used to render.
|
||||||
|
"""
|
||||||
|
view = renderer_context["view"]
|
||||||
|
request = renderer_context["request"]
|
||||||
|
response = renderer_context["response"]
|
||||||
|
|
||||||
|
renderer = self.get_default_renderer(view)
|
||||||
|
|
||||||
|
raw_data_post_form = self.get_raw_data_form(view, "POST", request)
|
||||||
|
raw_data_put_form = self.get_raw_data_form(view, "PUT", request)
|
||||||
|
raw_data_patch_form = self.get_raw_data_form(view, "PATCH", request)
|
||||||
|
raw_data_put_or_patch_form = raw_data_put_form or raw_data_patch_form
|
||||||
|
|
||||||
|
response_headers = dict(response.items())
|
||||||
|
renderer_content_type = ""
|
||||||
|
if renderer:
|
||||||
|
renderer_content_type = "%s" % renderer.media_type
|
||||||
|
if renderer.charset:
|
||||||
|
renderer_content_type += " ;%s" % renderer.charset
|
||||||
|
response_headers["Content-Type"] = renderer_content_type
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"content": self.get_content(renderer, data, accepted_media_type, renderer_context),
|
||||||
|
"view": view,
|
||||||
|
"request": request,
|
||||||
|
"response": response,
|
||||||
|
"description": self.get_description(view),
|
||||||
|
"name": self.get_name(view),
|
||||||
|
"version": VERSION,
|
||||||
|
"breadcrumblist": self.get_breadcrumbs(request),
|
||||||
|
"allowed_methods": view.allowed_methods,
|
||||||
|
"available_formats": [renderer.format for renderer in view.renderer_classes],
|
||||||
|
"response_headers": response_headers,
|
||||||
|
|
||||||
|
"put_form": self.get_rendered_html_form(view, "PUT", request),
|
||||||
|
"post_form": self.get_rendered_html_form(view, "POST", request),
|
||||||
|
"delete_form": self.get_rendered_html_form(view, "DELETE", request),
|
||||||
|
"options_form": self.get_rendered_html_form(view, "OPTIONS", request),
|
||||||
|
|
||||||
|
"raw_data_put_form": raw_data_put_form,
|
||||||
|
"raw_data_post_form": raw_data_post_form,
|
||||||
|
"raw_data_patch_form": raw_data_patch_form,
|
||||||
|
"raw_data_put_or_patch_form": raw_data_put_or_patch_form,
|
||||||
|
|
||||||
|
"display_edit_forms": bool(response.status_code != 403),
|
||||||
|
|
||||||
|
"api_settings": api_settings
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
|
||||||
|
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||||
|
"""
|
||||||
|
Render the HTML for the browsable API representation.
|
||||||
|
"""
|
||||||
|
self.accepted_media_type = accepted_media_type or ""
|
||||||
|
self.renderer_context = renderer_context or {}
|
||||||
|
|
||||||
|
template = loader.get_template(self.template)
|
||||||
|
context = self.get_context(data, accepted_media_type, renderer_context)
|
||||||
|
context = RequestContext(renderer_context["request"], context)
|
||||||
|
ret = template.render(context)
|
||||||
|
|
||||||
|
# Munge DELETE Response code to allow us to return content
|
||||||
|
# (Do this *after* we"ve rendered the template so that we include
|
||||||
|
# the normal deletion response code in the output)
|
||||||
|
response = renderer_context["response"]
|
||||||
|
if response.status_code == status.HTTP_204_NO_CONTENT:
|
||||||
|
response.status_code = status.HTTP_200_OK
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class MultiPartRenderer(BaseRenderer):
|
||||||
|
media_type = "multipart/form-data; boundary=BoUnDaRyStRiNg"
|
||||||
|
format = "multipart"
|
||||||
|
charset = "utf-8"
|
||||||
|
BOUNDARY = "BoUnDaRyStRiNg"
|
||||||
|
|
||||||
|
def render(self, data, accepted_media_type=None, renderer_context=None):
|
||||||
|
return encode_multipart(self.BOUNDARY, data)
|
||||||
|
|
|
@ -0,0 +1,440 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
"""
|
||||||
|
The Request class is used as a wrapper around the standard request object.
|
||||||
|
|
||||||
|
The wrapped request then offers a richer API, in particular :
|
||||||
|
|
||||||
|
- content automatically parsed according to `Content-Type` header,
|
||||||
|
and available as `request.DATA`
|
||||||
|
- full support of PUT method, including support for file uploads
|
||||||
|
- form overloading of HTTP method, content type and content
|
||||||
|
"""
|
||||||
|
from django.conf import settings
|
||||||
|
from django.http import QueryDict
|
||||||
|
from django.http.multipartparser import parse_header
|
||||||
|
from django.utils.datastructures import MultiValueDict
|
||||||
|
from django.utils.six import BytesIO
|
||||||
|
|
||||||
|
from taiga.base import exceptions
|
||||||
|
|
||||||
|
from . import HTTP_HEADER_ENCODING
|
||||||
|
from .settings import api_settings
|
||||||
|
|
||||||
|
|
||||||
|
def is_form_media_type(media_type):
|
||||||
|
"""
|
||||||
|
Return True if the media type is a valid form media type.
|
||||||
|
"""
|
||||||
|
base_media_type, params = parse_header(media_type.encode(HTTP_HEADER_ENCODING))
|
||||||
|
return (base_media_type == "application/x-www-form-urlencoded" or
|
||||||
|
base_media_type == "multipart/form-data")
|
||||||
|
|
||||||
|
|
||||||
|
class override_method(object):
|
||||||
|
"""
|
||||||
|
A context manager that temporarily overrides the method on a request,
|
||||||
|
additionally setting the `view.request` attribute.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
with override_method(view, request, "POST") as request:
|
||||||
|
... # Do stuff with `view` and `request`
|
||||||
|
"""
|
||||||
|
def __init__(self, view, request, method):
|
||||||
|
self.view = view
|
||||||
|
self.request = request
|
||||||
|
self.method = method
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
self.view.request = clone_request(self.request, self.method)
|
||||||
|
return self.view.request
|
||||||
|
|
||||||
|
def __exit__(self, *args, **kwarg):
|
||||||
|
self.view.request = self.request
|
||||||
|
|
||||||
|
|
||||||
|
class Empty(object):
|
||||||
|
"""
|
||||||
|
Placeholder for unset attributes.
|
||||||
|
Cannot use `None`, as that may be a valid value.
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def _hasattr(obj, name):
|
||||||
|
return not getattr(obj, name) is Empty
|
||||||
|
|
||||||
|
|
||||||
|
def clone_request(request, method):
|
||||||
|
"""
|
||||||
|
Internal helper method to clone a request, replacing with a different
|
||||||
|
HTTP method. Used for checking permissions against other methods.
|
||||||
|
"""
|
||||||
|
ret = Request(request=request._request,
|
||||||
|
parsers=request.parsers,
|
||||||
|
authenticators=request.authenticators,
|
||||||
|
negotiator=request.negotiator,
|
||||||
|
parser_context=request.parser_context)
|
||||||
|
ret._data = request._data
|
||||||
|
ret._files = request._files
|
||||||
|
ret._content_type = request._content_type
|
||||||
|
ret._stream = request._stream
|
||||||
|
ret._method = method
|
||||||
|
if hasattr(request, "_user"):
|
||||||
|
ret._user = request._user
|
||||||
|
if hasattr(request, "_auth"):
|
||||||
|
ret._auth = request._auth
|
||||||
|
if hasattr(request, "_authenticator"):
|
||||||
|
ret._authenticator = request._authenticator
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class ForcedAuthentication(object):
|
||||||
|
"""
|
||||||
|
This authentication class is used if the test client or request factory
|
||||||
|
forcibly authenticated the request.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, force_user, force_token):
|
||||||
|
self.force_user = force_user
|
||||||
|
self.force_token = force_token
|
||||||
|
|
||||||
|
def authenticate(self, request):
|
||||||
|
return (self.force_user, self.force_token)
|
||||||
|
|
||||||
|
|
||||||
|
class Request(object):
|
||||||
|
"""
|
||||||
|
Wrapper allowing to enhance a standard `HttpRequest` instance.
|
||||||
|
|
||||||
|
Kwargs:
|
||||||
|
- request(HttpRequest). The original request instance.
|
||||||
|
- parsers_classes(list/tuple). The parsers to use for parsing the
|
||||||
|
request content.
|
||||||
|
- authentication_classes(list/tuple). The authentications used to try
|
||||||
|
authenticating the request's user.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_METHOD_PARAM = api_settings.FORM_METHOD_OVERRIDE
|
||||||
|
_CONTENT_PARAM = api_settings.FORM_CONTENT_OVERRIDE
|
||||||
|
_CONTENTTYPE_PARAM = api_settings.FORM_CONTENTTYPE_OVERRIDE
|
||||||
|
|
||||||
|
def __init__(self, request, parsers=None, authenticators=None,
|
||||||
|
negotiator=None, parser_context=None):
|
||||||
|
self._request = request
|
||||||
|
self.parsers = parsers or ()
|
||||||
|
self.authenticators = authenticators or ()
|
||||||
|
self.negotiator = negotiator or self._default_negotiator()
|
||||||
|
self.parser_context = parser_context
|
||||||
|
self._data = Empty
|
||||||
|
self._files = Empty
|
||||||
|
self._method = Empty
|
||||||
|
self._content_type = Empty
|
||||||
|
self._stream = Empty
|
||||||
|
|
||||||
|
if self.parser_context is None:
|
||||||
|
self.parser_context = {}
|
||||||
|
self.parser_context["request"] = self
|
||||||
|
self.parser_context["encoding"] = request.encoding or settings.DEFAULT_CHARSET
|
||||||
|
|
||||||
|
force_user = getattr(request, "_force_auth_user", None)
|
||||||
|
force_token = getattr(request, "_force_auth_token", None)
|
||||||
|
if (force_user is not None or force_token is not None):
|
||||||
|
forced_auth = ForcedAuthentication(force_user, force_token)
|
||||||
|
self.authenticators = (forced_auth,)
|
||||||
|
|
||||||
|
def _default_negotiator(self):
|
||||||
|
return api_settings.DEFAULT_CONTENT_NEGOTIATION_CLASS()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def method(self):
|
||||||
|
"""
|
||||||
|
Returns the HTTP method.
|
||||||
|
|
||||||
|
This allows the `method` to be overridden by using a hidden `form`
|
||||||
|
field on a form POST request.
|
||||||
|
"""
|
||||||
|
if not _hasattr(self, "_method"):
|
||||||
|
self._load_method_and_content_type()
|
||||||
|
return self._method
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_type(self):
|
||||||
|
"""
|
||||||
|
Returns the content type header.
|
||||||
|
|
||||||
|
This should be used instead of `request.META.get("HTTP_CONTENT_TYPE")`,
|
||||||
|
as it allows the content type to be overridden by using a hidden form
|
||||||
|
field on a form POST request.
|
||||||
|
"""
|
||||||
|
if not _hasattr(self, "_content_type"):
|
||||||
|
self._load_method_and_content_type()
|
||||||
|
return self._content_type
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stream(self):
|
||||||
|
"""
|
||||||
|
Returns an object that may be used to stream the request content.
|
||||||
|
"""
|
||||||
|
if not _hasattr(self, "_stream"):
|
||||||
|
self._load_stream()
|
||||||
|
return self._stream
|
||||||
|
|
||||||
|
@property
|
||||||
|
def QUERY_PARAMS(self):
|
||||||
|
"""
|
||||||
|
More semantically correct name for request.GET.
|
||||||
|
"""
|
||||||
|
return self._request.GET
|
||||||
|
|
||||||
|
@property
|
||||||
|
def DATA(self):
|
||||||
|
"""
|
||||||
|
Parses the request body and returns the data.
|
||||||
|
|
||||||
|
Similar to usual behaviour of `request.POST`, except that it handles
|
||||||
|
arbitrary parsers, and also works on methods other than POST (eg PUT).
|
||||||
|
"""
|
||||||
|
if not _hasattr(self, "_data"):
|
||||||
|
self._load_data_and_files()
|
||||||
|
return self._data
|
||||||
|
|
||||||
|
@property
|
||||||
|
def FILES(self):
|
||||||
|
"""
|
||||||
|
Parses the request body and returns any files uploaded in the request.
|
||||||
|
|
||||||
|
Similar to usual behaviour of `request.FILES`, except that it handles
|
||||||
|
arbitrary parsers, and also works on methods other than POST (eg PUT).
|
||||||
|
"""
|
||||||
|
if not _hasattr(self, "_files"):
|
||||||
|
self._load_data_and_files()
|
||||||
|
return self._files
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user(self):
|
||||||
|
"""
|
||||||
|
Returns the user associated with the current request, as authenticated
|
||||||
|
by the authentication classes provided to the request.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "_user"):
|
||||||
|
self._authenticate()
|
||||||
|
return self._user
|
||||||
|
|
||||||
|
@user.setter
|
||||||
|
def user(self, value):
|
||||||
|
"""
|
||||||
|
Sets the user on the current request. This is necessary to maintain
|
||||||
|
compatibility with django.contrib.auth where the user property is
|
||||||
|
set in the login and logout functions.
|
||||||
|
"""
|
||||||
|
self._user = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth(self):
|
||||||
|
"""
|
||||||
|
Returns any non-user authentication information associated with the
|
||||||
|
request, such as an authentication token.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "_auth"):
|
||||||
|
self._authenticate()
|
||||||
|
return self._auth
|
||||||
|
|
||||||
|
@auth.setter
|
||||||
|
def auth(self, value):
|
||||||
|
"""
|
||||||
|
Sets any non-user authentication information associated with the
|
||||||
|
request, such as an authentication token.
|
||||||
|
"""
|
||||||
|
self._auth = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def successful_authenticator(self):
|
||||||
|
"""
|
||||||
|
Return the instance of the authentication instance class that was used
|
||||||
|
to authenticate the request, or `None`.
|
||||||
|
"""
|
||||||
|
if not hasattr(self, "_authenticator"):
|
||||||
|
self._authenticate()
|
||||||
|
return self._authenticator
|
||||||
|
|
||||||
|
def _load_data_and_files(self):
|
||||||
|
"""
|
||||||
|
Parses the request content into self.DATA and self.FILES.
|
||||||
|
"""
|
||||||
|
if not _hasattr(self, "_content_type"):
|
||||||
|
self._load_method_and_content_type()
|
||||||
|
|
||||||
|
if not _hasattr(self, "_data"):
|
||||||
|
self._data, self._files = self._parse()
|
||||||
|
|
||||||
|
def _load_method_and_content_type(self):
|
||||||
|
"""
|
||||||
|
Sets the method and content_type, and then check if they"ve
|
||||||
|
been overridden.
|
||||||
|
"""
|
||||||
|
self._content_type = self.META.get("HTTP_CONTENT_TYPE",
|
||||||
|
self.META.get("CONTENT_TYPE", ""))
|
||||||
|
|
||||||
|
self._perform_form_overloading()
|
||||||
|
|
||||||
|
if not _hasattr(self, "_method"):
|
||||||
|
self._method = self._request.method
|
||||||
|
|
||||||
|
# Allow X-HTTP-METHOD-OVERRIDE header
|
||||||
|
self._method = self.META.get("HTTP_X_HTTP_METHOD_OVERRIDE",
|
||||||
|
self._method)
|
||||||
|
|
||||||
|
def _load_stream(self):
|
||||||
|
"""
|
||||||
|
Return the content body of the request, as a stream.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
content_length = int(self.META.get("CONTENT_LENGTH",
|
||||||
|
self.META.get("HTTP_CONTENT_LENGTH")))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
content_length = 0
|
||||||
|
|
||||||
|
if content_length == 0:
|
||||||
|
self._stream = None
|
||||||
|
elif hasattr(self._request, "read"):
|
||||||
|
self._stream = self._request
|
||||||
|
else:
|
||||||
|
self._stream = BytesIO(self.raw_post_data)
|
||||||
|
|
||||||
|
def _perform_form_overloading(self):
|
||||||
|
"""
|
||||||
|
If this is a form POST request, then we need to check if the method and
|
||||||
|
content/content_type have been overridden by setting them in hidden
|
||||||
|
form fields or not.
|
||||||
|
"""
|
||||||
|
|
||||||
|
USE_FORM_OVERLOADING = (
|
||||||
|
self._METHOD_PARAM or
|
||||||
|
(self._CONTENT_PARAM and self._CONTENTTYPE_PARAM)
|
||||||
|
)
|
||||||
|
|
||||||
|
# We only need to use form overloading on form POST requests.
|
||||||
|
if (not USE_FORM_OVERLOADING
|
||||||
|
or self._request.method != "POST"
|
||||||
|
or not is_form_media_type(self._content_type)):
|
||||||
|
return
|
||||||
|
|
||||||
|
# At this point we"re committed to parsing the request as form data.
|
||||||
|
self._data = self._request.POST
|
||||||
|
self._files = self._request.FILES
|
||||||
|
|
||||||
|
# Method overloading - change the method and remove the param from the content.
|
||||||
|
if (self._METHOD_PARAM and
|
||||||
|
self._METHOD_PARAM in self._data):
|
||||||
|
self._method = self._data[self._METHOD_PARAM].upper()
|
||||||
|
|
||||||
|
# Content overloading - modify the content type, and force re-parse.
|
||||||
|
if (self._CONTENT_PARAM and
|
||||||
|
self._CONTENTTYPE_PARAM and
|
||||||
|
self._CONTENT_PARAM in self._data and
|
||||||
|
self._CONTENTTYPE_PARAM in self._data):
|
||||||
|
self._content_type = self._data[self._CONTENTTYPE_PARAM]
|
||||||
|
self._stream = BytesIO(self._data[self._CONTENT_PARAM].encode(self.parser_context["encoding"]))
|
||||||
|
self._data, self._files = (Empty, Empty)
|
||||||
|
|
||||||
|
def _parse(self):
|
||||||
|
"""
|
||||||
|
Parse the request content, returning a two-tuple of (data, files)
|
||||||
|
|
||||||
|
May raise an `UnsupportedMediaType`, or `ParseError` exception.
|
||||||
|
"""
|
||||||
|
stream = self.stream
|
||||||
|
media_type = self.content_type
|
||||||
|
|
||||||
|
if stream is None or media_type is None:
|
||||||
|
empty_data = QueryDict("", self._request._encoding)
|
||||||
|
empty_files = MultiValueDict()
|
||||||
|
return (empty_data, empty_files)
|
||||||
|
|
||||||
|
parser = self.negotiator.select_parser(self, self.parsers)
|
||||||
|
|
||||||
|
if not parser:
|
||||||
|
raise exceptions.UnsupportedMediaType(media_type)
|
||||||
|
|
||||||
|
try:
|
||||||
|
parsed = parser.parse(stream, media_type, self.parser_context)
|
||||||
|
except:
|
||||||
|
# If we get an exception during parsing, fill in empty data and
|
||||||
|
# re-raise. Ensures we don't simply repeat the error when
|
||||||
|
# attempting to render the browsable renderer response, or when
|
||||||
|
# logging the request or similar.
|
||||||
|
self._data = QueryDict("", self._request._encoding)
|
||||||
|
self._files = MultiValueDict()
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Parser classes may return the raw data, or a
|
||||||
|
# DataAndFiles object. Unpack the result as required.
|
||||||
|
try:
|
||||||
|
return (parsed.data, parsed.files)
|
||||||
|
except AttributeError:
|
||||||
|
empty_files = MultiValueDict()
|
||||||
|
return (parsed, empty_files)
|
||||||
|
|
||||||
|
def _authenticate(self):
|
||||||
|
"""
|
||||||
|
Attempt to authenticate the request using each authentication instance
|
||||||
|
in turn.
|
||||||
|
Returns a three-tuple of (authenticator, user, authtoken).
|
||||||
|
"""
|
||||||
|
for authenticator in self.authenticators:
|
||||||
|
try:
|
||||||
|
user_auth_tuple = authenticator.authenticate(self)
|
||||||
|
except exceptions.APIException:
|
||||||
|
self._not_authenticated()
|
||||||
|
raise
|
||||||
|
|
||||||
|
if not user_auth_tuple is None:
|
||||||
|
self._authenticator = authenticator
|
||||||
|
self._user, self._auth = user_auth_tuple
|
||||||
|
return
|
||||||
|
|
||||||
|
self._not_authenticated()
|
||||||
|
|
||||||
|
def _not_authenticated(self):
|
||||||
|
"""
|
||||||
|
Return a three-tuple of (authenticator, user, authtoken), representing
|
||||||
|
an unauthenticated request.
|
||||||
|
|
||||||
|
By default this will be (None, AnonymousUser, None).
|
||||||
|
"""
|
||||||
|
self._authenticator = None
|
||||||
|
|
||||||
|
if api_settings.UNAUTHENTICATED_USER:
|
||||||
|
self._user = api_settings.UNAUTHENTICATED_USER()
|
||||||
|
else:
|
||||||
|
self._user = None
|
||||||
|
|
||||||
|
if api_settings.UNAUTHENTICATED_TOKEN:
|
||||||
|
self._auth = api_settings.UNAUTHENTICATED_TOKEN()
|
||||||
|
else:
|
||||||
|
self._auth = None
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
"""
|
||||||
|
Proxy other attributes to the underlying HttpRequest object.
|
||||||
|
"""
|
||||||
|
return getattr(self._request, attr)
|
|
@ -0,0 +1,41 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
"""
|
||||||
|
Provide reverse functions that return fully qualified URLs
|
||||||
|
"""
|
||||||
|
from django.core.urlresolvers import reverse as django_reverse
|
||||||
|
from django.utils.functional import lazy
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(viewname, args=None, kwargs=None, request=None, format=None, **extra):
|
||||||
|
"""
|
||||||
|
Same as `django.core.urlresolvers.reverse`, but optionally takes a request
|
||||||
|
and returns a fully qualified URL, using the request to get the base URL.
|
||||||
|
"""
|
||||||
|
if format is not None:
|
||||||
|
kwargs = kwargs or {}
|
||||||
|
kwargs["format"] = format
|
||||||
|
url = django_reverse(viewname, args=args, kwargs=kwargs, **extra)
|
||||||
|
if request:
|
||||||
|
return request.build_absolute_uri(url)
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
reverse_lazy = lazy(reverse, str)
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,226 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2015, Tom Christie
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
Settings for REST framework are all namespaced in the REST_FRAMEWORK setting.
|
||||||
|
For example your project's `settings.py` file might look like this:
|
||||||
|
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
"DEFAULT_RENDERER_CLASSES": (
|
||||||
|
"taiga.base.api.renderers.JSONRenderer",
|
||||||
|
)
|
||||||
|
"DEFAULT_PARSER_CLASSES": (
|
||||||
|
"taiga.base.api.parsers.JSONParser",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
This module provides the `api_setting` object, that is used to access
|
||||||
|
REST framework settings, checking for user settings first, then falling
|
||||||
|
back to the defaults.
|
||||||
|
"""
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import importlib
|
||||||
|
from django.utils import six
|
||||||
|
|
||||||
|
from . import ISO_8601
|
||||||
|
|
||||||
|
|
||||||
|
USER_SETTINGS = getattr(settings, "REST_FRAMEWORK", None)
|
||||||
|
|
||||||
|
DEFAULTS = {
|
||||||
|
# Base API policies
|
||||||
|
"DEFAULT_RENDERER_CLASSES": (
|
||||||
|
"taiga.base.api.renderers.JSONRenderer",
|
||||||
|
"taiga.base.api.renderers.BrowsableAPIRenderer",
|
||||||
|
),
|
||||||
|
"DEFAULT_PARSER_CLASSES": (
|
||||||
|
"taiga.base.api.parsers.JSONParser",
|
||||||
|
"taiga.base.api.parsers.FormParser",
|
||||||
|
"taiga.base.api.parsers.MultiPartParser"
|
||||||
|
),
|
||||||
|
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||||
|
"taiga.base.api.authentication.SessionAuthentication",
|
||||||
|
"taiga.base.api.authentication.BasicAuthentication"
|
||||||
|
),
|
||||||
|
"DEFAULT_PERMISSION_CLASSES": (
|
||||||
|
"taiga.base.api.permissions.AllowAny",
|
||||||
|
),
|
||||||
|
"DEFAULT_THROTTLE_CLASSES": (
|
||||||
|
),
|
||||||
|
"DEFAULT_CONTENT_NEGOTIATION_CLASS":
|
||||||
|
"taiga.base.api.negotiation.DefaultContentNegotiation",
|
||||||
|
|
||||||
|
# Genric view behavior
|
||||||
|
"DEFAULT_MODEL_SERIALIZER_CLASS":
|
||||||
|
"taiga.base.api.serializers.ModelSerializer",
|
||||||
|
"DEFAULT_FILTER_BACKENDS": (),
|
||||||
|
|
||||||
|
# Throttling
|
||||||
|
"DEFAULT_THROTTLE_RATES": {
|
||||||
|
"user": None,
|
||||||
|
"anon": None,
|
||||||
|
},
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
"PAGINATE_BY": None,
|
||||||
|
"PAGINATE_BY_PARAM": None,
|
||||||
|
"MAX_PAGINATE_BY": None,
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
"UNAUTHENTICATED_USER": "django.contrib.auth.models.AnonymousUser",
|
||||||
|
"UNAUTHENTICATED_TOKEN": None,
|
||||||
|
|
||||||
|
# View configuration
|
||||||
|
"VIEW_NAME_FUNCTION": "taiga.base.api.views.get_view_name",
|
||||||
|
"VIEW_DESCRIPTION_FUNCTION": "taiga.base.api.views.get_view_description",
|
||||||
|
|
||||||
|
# Exception handling
|
||||||
|
"EXCEPTION_HANDLER": "taiga.base.api.views.exception_handler",
|
||||||
|
|
||||||
|
# Testing
|
||||||
|
"TEST_REQUEST_RENDERER_CLASSES": (
|
||||||
|
"taiga.base.api.renderers.MultiPartRenderer",
|
||||||
|
"taiga.base.api.renderers.JSONRenderer"
|
||||||
|
),
|
||||||
|
"TEST_REQUEST_DEFAULT_FORMAT": "multipart",
|
||||||
|
|
||||||
|
# Browser enhancements
|
||||||
|
"FORM_METHOD_OVERRIDE": "_method",
|
||||||
|
"FORM_CONTENT_OVERRIDE": "_content",
|
||||||
|
"FORM_CONTENTTYPE_OVERRIDE": "_content_type",
|
||||||
|
"URL_ACCEPT_OVERRIDE": "accept",
|
||||||
|
"URL_FORMAT_OVERRIDE": "format",
|
||||||
|
|
||||||
|
"FORMAT_SUFFIX_KWARG": "format",
|
||||||
|
"URL_FIELD_NAME": "url",
|
||||||
|
|
||||||
|
# Input and output formats
|
||||||
|
"DATE_INPUT_FORMATS": (
|
||||||
|
ISO_8601,
|
||||||
|
),
|
||||||
|
"DATE_FORMAT": None,
|
||||||
|
|
||||||
|
"DATETIME_INPUT_FORMATS": (
|
||||||
|
ISO_8601,
|
||||||
|
),
|
||||||
|
"DATETIME_FORMAT": None,
|
||||||
|
|
||||||
|
"TIME_INPUT_FORMATS": (
|
||||||
|
ISO_8601,
|
||||||
|
),
|
||||||
|
"TIME_FORMAT": None,
|
||||||
|
|
||||||
|
# Pending deprecation
|
||||||
|
"FILTER_BACKEND": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# List of settings that may be in string import notation.
|
||||||
|
IMPORT_STRINGS = (
|
||||||
|
"DEFAULT_RENDERER_CLASSES",
|
||||||
|
"DEFAULT_PARSER_CLASSES",
|
||||||
|
"DEFAULT_AUTHENTICATION_CLASSES",
|
||||||
|
"DEFAULT_PERMISSION_CLASSES",
|
||||||
|
"DEFAULT_THROTTLE_CLASSES",
|
||||||
|
"DEFAULT_CONTENT_NEGOTIATION_CLASS",
|
||||||
|
"DEFAULT_MODEL_SERIALIZER_CLASS",
|
||||||
|
"DEFAULT_FILTER_BACKENDS",
|
||||||
|
"EXCEPTION_HANDLER",
|
||||||
|
"FILTER_BACKEND",
|
||||||
|
"TEST_REQUEST_RENDERER_CLASSES",
|
||||||
|
"UNAUTHENTICATED_USER",
|
||||||
|
"UNAUTHENTICATED_TOKEN",
|
||||||
|
"VIEW_NAME_FUNCTION",
|
||||||
|
"VIEW_DESCRIPTION_FUNCTION"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def perform_import(val, setting_name):
|
||||||
|
"""
|
||||||
|
If the given setting is a string import notation,
|
||||||
|
then perform the necessary import or imports.
|
||||||
|
"""
|
||||||
|
if isinstance(val, six.string_types):
|
||||||
|
return import_from_string(val, setting_name)
|
||||||
|
elif isinstance(val, (list, tuple)):
|
||||||
|
return [import_from_string(item, setting_name) for item in val]
|
||||||
|
return val
|
||||||
|
|
||||||
|
|
||||||
|
def import_from_string(val, setting_name):
|
||||||
|
"""
|
||||||
|
Attempt to import a class from a string representation.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Nod to tastypie's use of importlib.
|
||||||
|
parts = val.split('.')
|
||||||
|
module_path, class_name = '.'.join(parts[:-1]), parts[-1]
|
||||||
|
module = importlib.import_module(module_path)
|
||||||
|
return getattr(module, class_name)
|
||||||
|
except ImportError as e:
|
||||||
|
msg = "Could not import '%s' for API setting '%s'. %s: %s." % (val, setting_name, e.__class__.__name__, e)
|
||||||
|
raise ImportError(msg)
|
||||||
|
|
||||||
|
|
||||||
|
class APISettings(object):
|
||||||
|
"""
|
||||||
|
A settings object, that allows API settings to be accessed as properties.
|
||||||
|
For example:
|
||||||
|
|
||||||
|
from taiga.base.api.settings import api_settings
|
||||||
|
print api_settings.DEFAULT_RENDERER_CLASSES
|
||||||
|
|
||||||
|
Any setting with string import paths will be automatically resolved
|
||||||
|
and return the class, rather than the string literal.
|
||||||
|
"""
|
||||||
|
def __init__(self, user_settings=None, defaults=None, import_strings=None):
|
||||||
|
self.user_settings = user_settings or {}
|
||||||
|
self.defaults = defaults or {}
|
||||||
|
self.import_strings = import_strings or ()
|
||||||
|
|
||||||
|
def __getattr__(self, attr):
|
||||||
|
if attr not in self.defaults.keys():
|
||||||
|
raise AttributeError("Invalid API setting: '%s'" % attr)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if present in user settings
|
||||||
|
val = self.user_settings[attr]
|
||||||
|
except KeyError:
|
||||||
|
# Fall back to defaults
|
||||||
|
val = self.defaults[attr]
|
||||||
|
|
||||||
|
# Coerce import strings into classes
|
||||||
|
if val and attr in self.import_strings:
|
||||||
|
val = perform_import(val, attr)
|
||||||
|
|
||||||
|
self.validate_setting(attr, val)
|
||||||
|
|
||||||
|
# Cache the result
|
||||||
|
setattr(self, attr, val)
|
||||||
|
return val
|
||||||
|
|
||||||
|
def validate_setting(self, attr, val):
|
||||||
|
if attr == "FILTER_BACKEND" and val is not None:
|
||||||
|
# Make sure we can initialize the class
|
||||||
|
val()
|
||||||
|
|
||||||
|
api_settings = APISettings(USER_SETTINGS, DEFAULTS, IMPORT_STRINGS)
|
|
@ -0,0 +1,206 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
* Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
* Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
* This code is partially taken from django-rest-framework:
|
||||||
|
* Copyright (c) 2011-2014, Tom Christie
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
|
||||||
|
This CSS file contains some tweaks specific to the included Bootstrap theme.
|
||||||
|
It's separate from `style.css` so that it can be easily overridden by replacing
|
||||||
|
a single block in the template.
|
||||||
|
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
.form-actions {
|
||||||
|
background: transparent;
|
||||||
|
border-top-color: transparent;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-inverse .brand a {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
.navbar-inverse .brand:hover a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* custom navigation styles */
|
||||||
|
.wrapper .navbar{
|
||||||
|
width: 100%;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .navbar-inner{
|
||||||
|
background: #2C2C2C;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-top: 5px solid #A30000;
|
||||||
|
border-radius: 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .navbar-inner .nav li, .navbar .navbar-inner .nav li a, .navbar .navbar-inner .brand:hover{
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-list > .active > a, .nav-list > .active > a:hover {
|
||||||
|
background: #2c2c2c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar .navbar-inner .dropdown-menu li a, .navbar .navbar-inner .dropdown-menu li{
|
||||||
|
color: #A30000;
|
||||||
|
}
|
||||||
|
.navbar .navbar-inner .dropdown-menu li a:hover{
|
||||||
|
background: #eeeeee;
|
||||||
|
color: #c20000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*=== dabapps bootstrap styles ====*/
|
||||||
|
|
||||||
|
html{
|
||||||
|
width:100%;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body, .navbar .navbar-inner .container-fluid {
|
||||||
|
max-width: 1150px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
body{
|
||||||
|
background: url("../img/grid.png") repeat-x;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content{
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* sticky footer and footer */
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.wrapper {
|
||||||
|
min-height: 100%;
|
||||||
|
height: auto !important;
|
||||||
|
height: 100%;
|
||||||
|
margin: 0 auto -60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-switcher {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.well {
|
||||||
|
-webkit-box-shadow: none;
|
||||||
|
-moz-box-shadow: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.well .form-actions {
|
||||||
|
padding-bottom: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.well form {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.well form .help-block {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs {
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs > li {
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs li a {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs > .active > a {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-tabs > .active > a:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabbable.first-tab-active .tab-content
|
||||||
|
{
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer, #push {
|
||||||
|
height: 60px; /* .push must be the same height as .footer */
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer{
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer p {
|
||||||
|
text-align: center;
|
||||||
|
color: gray;
|
||||||
|
border-top: 1px solid #DDD;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer a {
|
||||||
|
color: gray;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#footer a:hover {
|
||||||
|
color: gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* custom general page styles */
|
||||||
|
.hero-unit h2, .hero-unit h1{
|
||||||
|
color: #A30000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body a, body a{
|
||||||
|
color: #A30000;
|
||||||
|
}
|
||||||
|
|
||||||
|
body a:hover{
|
||||||
|
color: #c20000;
|
||||||
|
}
|
||||||
|
|
||||||
|
#content a span{
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-info {
|
||||||
|
clear:both;
|
||||||
|
}
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,91 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
* Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
* Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
* This code is partially taken from django-rest-framework:
|
||||||
|
* Copyright (c) 2011-2014, Tom Christie
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* The navbar is fixed at >= 980px wide, so add padding to the body to prevent
|
||||||
|
content running up underneath it. */
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2, h3 {
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resource-description, .response-info {
|
||||||
|
margin-bottom: 2em;
|
||||||
|
}
|
||||||
|
.version:before {
|
||||||
|
content: "v";
|
||||||
|
opacity: 0.6;
|
||||||
|
padding-right: 0.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.version {
|
||||||
|
font-size: 70%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.format-option {
|
||||||
|
font-family: Menlo, Consolas, "Andale Mono", "Lucida Console", monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-form {
|
||||||
|
float: right;
|
||||||
|
margin-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul.breadcrumb {
|
||||||
|
margin: 58px 0 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
form select, form input, form textarea {
|
||||||
|
width: 90%;
|
||||||
|
}
|
||||||
|
|
||||||
|
form select[multiple] {
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
/* To allow tooltips to work on disabled elements */
|
||||||
|
.disabled-tooltip-shield {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.errorlist {
|
||||||
|
margin-top: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
overflow: auto;
|
||||||
|
word-wrap: normal;
|
||||||
|
white-space: pre;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
border-bottom: none;
|
||||||
|
padding-bottom: 0px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,50 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
* Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
* Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
* This code is partially taken from django-rest-framework:
|
||||||
|
* Copyright (c) 2011-2014, Tom Christie
|
||||||
|
*/
|
||||||
|
.com { color: #93a1a1; }
|
||||||
|
.lit { color: #195f91; }
|
||||||
|
.pun, .opn, .clo { color: #93a1a1; }
|
||||||
|
.fun { color: #dc322f; }
|
||||||
|
.str, .atv { color: #D14; }
|
||||||
|
.kwd, .prettyprint .tag { color: #1e347b; }
|
||||||
|
.typ, .atn, .dec, .var { color: teal; }
|
||||||
|
.pln { color: #48484c; }
|
||||||
|
|
||||||
|
.prettyprint {
|
||||||
|
padding: 8px;
|
||||||
|
background-color: #f7f7f9;
|
||||||
|
border: 1px solid #e1e1e8;
|
||||||
|
}
|
||||||
|
.prettyprint.linenums {
|
||||||
|
-webkit-box-shadow: inset 40px 0 0 #fbfbfc, inset 41px 0 0 #ececf0;
|
||||||
|
-moz-box-shadow: inset 40px 0 0 #fbfbfc, inset 41px 0 0 #ececf0;
|
||||||
|
box-shadow: inset 40px 0 0 #fbfbfc, inset 41px 0 0 #ececf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Specify class=linenums on a pre to get line numbering */
|
||||||
|
ol.linenums {
|
||||||
|
margin: 0 0 0 33px; /* IE indents via margin-left */
|
||||||
|
}
|
||||||
|
ol.linenums li {
|
||||||
|
padding-left: 12px;
|
||||||
|
color: #bebec5;
|
||||||
|
line-height: 20px;
|
||||||
|
text-shadow: 0 1px 0 #fff;
|
||||||
|
}
|
Binary file not shown.
After Width: | Height: | Size: 8.6 KiB |
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.4 KiB |
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,78 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
* Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
* Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
* This code is partially taken from django-rest-framework:
|
||||||
|
* Copyright (c) 2011-2014, Tom Christie
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
function getCookie(c_name)
|
||||||
|
{
|
||||||
|
// From http://www.w3schools.com/js/js_cookies.asp
|
||||||
|
var c_value = document.cookie;
|
||||||
|
var c_start = c_value.indexOf(" " + c_name + "=");
|
||||||
|
if (c_start == -1) {
|
||||||
|
c_start = c_value.indexOf(c_name + "=");
|
||||||
|
}
|
||||||
|
if (c_start == -1) {
|
||||||
|
c_value = null;
|
||||||
|
} else {
|
||||||
|
c_start = c_value.indexOf("=", c_start) + 1;
|
||||||
|
var c_end = c_value.indexOf(";", c_start);
|
||||||
|
if (c_end == -1) {
|
||||||
|
c_end = c_value.length;
|
||||||
|
}
|
||||||
|
c_value = unescape(c_value.substring(c_start,c_end));
|
||||||
|
}
|
||||||
|
return c_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON highlighting.
|
||||||
|
prettyPrint();
|
||||||
|
|
||||||
|
// Bootstrap tooltips.
|
||||||
|
$('.js-tooltip').tooltip({
|
||||||
|
delay: 1000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Deal with rounded tab styling after tab clicks.
|
||||||
|
$('a[data-toggle="tab"]:first').on('shown', function (e) {
|
||||||
|
$(e.target).parents('.tabbable').addClass('first-tab-active');
|
||||||
|
});
|
||||||
|
$('a[data-toggle="tab"]:not(:first)').on('shown', function (e) {
|
||||||
|
$(e.target).parents('.tabbable').removeClass('first-tab-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
$('a[data-toggle="tab"]').click(function(){
|
||||||
|
document.cookie="tabstyle=" + this.name + "; path=/";
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store tab preference in cookies & display appropriate tab on load.
|
||||||
|
var selectedTab = null;
|
||||||
|
var selectedTabName = getCookie('tabstyle');
|
||||||
|
|
||||||
|
if (selectedTabName) {
|
||||||
|
selectedTab = $('.form-switcher a[name=' + selectedTabName + ']');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedTab && selectedTab.length > 0) {
|
||||||
|
// Display whichever tab is selected.
|
||||||
|
selectedTab.tab('show');
|
||||||
|
} else {
|
||||||
|
// If no tab selected, display rightmost tab.
|
||||||
|
$('.form-switcher a:first').tab('show');
|
||||||
|
}
|
File diff suppressed because one or more lines are too long
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
* Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
* Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*
|
||||||
|
* This code is partially taken from django-rest-framework:
|
||||||
|
* Copyright (c) 2011-2014, Tom Christie
|
||||||
|
*/
|
||||||
|
var q=null;window.PR_SHOULD_USE_CONTINUATION=!0;
|
||||||
|
(function(){function L(a){function m(a){var f=a.charCodeAt(0);if(f!==92)return f;var b=a.charAt(1);return(f=r[b])?f:"0"<=b&&b<="7"?parseInt(a.substring(1),8):b==="u"||b==="x"?parseInt(a.substring(2),16):a.charCodeAt(1)}function e(a){if(a<32)return(a<16?"\\x0":"\\x")+a.toString(16);a=String.fromCharCode(a);if(a==="\\"||a==="-"||a==="["||a==="]")a="\\"+a;return a}function h(a){for(var f=a.substring(1,a.length-1).match(/\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\[0-3][0-7]{0,2}|\\[0-7]{1,2}|\\[\S\s]|[^\\]/g),a=
|
||||||
|
[],b=[],o=f[0]==="^",c=o?1:0,i=f.length;c<i;++c){var j=f[c];if(/\\[bdsw]/i.test(j))a.push(j);else{var j=m(j),d;c+2<i&&"-"===f[c+1]?(d=m(f[c+2]),c+=2):d=j;b.push([j,d]);d<65||j>122||(d<65||j>90||b.push([Math.max(65,j)|32,Math.min(d,90)|32]),d<97||j>122||b.push([Math.max(97,j)&-33,Math.min(d,122)&-33]))}}b.sort(function(a,f){return a[0]-f[0]||f[1]-a[1]});f=[];j=[NaN,NaN];for(c=0;c<b.length;++c)i=b[c],i[0]<=j[1]+1?j[1]=Math.max(j[1],i[1]):f.push(j=i);b=["["];o&&b.push("^");b.push.apply(b,a);for(c=0;c<
|
||||||
|
f.length;++c)i=f[c],b.push(e(i[0])),i[1]>i[0]&&(i[1]+1>i[0]&&b.push("-"),b.push(e(i[1])));b.push("]");return b.join("")}function y(a){for(var f=a.source.match(/\[(?:[^\\\]]|\\[\S\s])*]|\\u[\dA-Fa-f]{4}|\\x[\dA-Fa-f]{2}|\\\d+|\\[^\dux]|\(\?[!:=]|[()^]|[^()[\\^]+/g),b=f.length,d=[],c=0,i=0;c<b;++c){var j=f[c];j==="("?++i:"\\"===j.charAt(0)&&(j=+j.substring(1))&&j<=i&&(d[j]=-1)}for(c=1;c<d.length;++c)-1===d[c]&&(d[c]=++t);for(i=c=0;c<b;++c)j=f[c],j==="("?(++i,d[i]===void 0&&(f[c]="(?:")):"\\"===j.charAt(0)&&
|
||||||
|
(j=+j.substring(1))&&j<=i&&(f[c]="\\"+d[i]);for(i=c=0;c<b;++c)"^"===f[c]&&"^"!==f[c+1]&&(f[c]="");if(a.ignoreCase&&s)for(c=0;c<b;++c)j=f[c],a=j.charAt(0),j.length>=2&&a==="["?f[c]=h(j):a!=="\\"&&(f[c]=j.replace(/[A-Za-z]/g,function(a){a=a.charCodeAt(0);return"["+String.fromCharCode(a&-33,a|32)+"]"}));return f.join("")}for(var t=0,s=!1,l=!1,p=0,d=a.length;p<d;++p){var g=a[p];if(g.ignoreCase)l=!0;else if(/[a-z]/i.test(g.source.replace(/\\u[\da-f]{4}|\\x[\da-f]{2}|\\[^UXux]/gi,""))){s=!0;l=!1;break}}for(var r=
|
||||||
|
{b:8,t:9,n:10,v:11,f:12,r:13},n=[],p=0,d=a.length;p<d;++p){g=a[p];if(g.global||g.multiline)throw Error(""+g);n.push("(?:"+y(g)+")")}return RegExp(n.join("|"),l?"gi":"g")}function M(a){function m(a){switch(a.nodeType){case 1:if(e.test(a.className))break;for(var g=a.firstChild;g;g=g.nextSibling)m(g);g=a.nodeName;if("BR"===g||"LI"===g)h[s]="\n",t[s<<1]=y++,t[s++<<1|1]=a;break;case 3:case 4:g=a.nodeValue,g.length&&(g=p?g.replace(/\r\n?/g,"\n"):g.replace(/[\t\n\r ]+/g," "),h[s]=g,t[s<<1]=y,y+=g.length,
|
||||||
|
t[s++<<1|1]=a)}}var e=/(?:^|\s)nocode(?:\s|$)/,h=[],y=0,t=[],s=0,l;a.currentStyle?l=a.currentStyle.whiteSpace:window.getComputedStyle&&(l=document.defaultView.getComputedStyle(a,q).getPropertyValue("white-space"));var p=l&&"pre"===l.substring(0,3);m(a);return{a:h.join("").replace(/\n$/,""),c:t}}function B(a,m,e,h){m&&(a={a:m,d:a},e(a),h.push.apply(h,a.e))}function x(a,m){function e(a){for(var l=a.d,p=[l,"pln"],d=0,g=a.a.match(y)||[],r={},n=0,z=g.length;n<z;++n){var f=g[n],b=r[f],o=void 0,c;if(typeof b===
|
||||||
|
"string")c=!1;else{var i=h[f.charAt(0)];if(i)o=f.match(i[1]),b=i[0];else{for(c=0;c<t;++c)if(i=m[c],o=f.match(i[1])){b=i[0];break}o||(b="pln")}if((c=b.length>=5&&"lang-"===b.substring(0,5))&&!(o&&typeof o[1]==="string"))c=!1,b="src";c||(r[f]=b)}i=d;d+=f.length;if(c){c=o[1];var j=f.indexOf(c),k=j+c.length;o[2]&&(k=f.length-o[2].length,j=k-c.length);b=b.substring(5);B(l+i,f.substring(0,j),e,p);B(l+i+j,c,C(b,c),p);B(l+i+k,f.substring(k),e,p)}else p.push(l+i,b)}a.e=p}var h={},y;(function(){for(var e=a.concat(m),
|
||||||
|
l=[],p={},d=0,g=e.length;d<g;++d){var r=e[d],n=r[3];if(n)for(var k=n.length;--k>=0;)h[n.charAt(k)]=r;r=r[1];n=""+r;p.hasOwnProperty(n)||(l.push(r),p[n]=q)}l.push(/[\S\s]/);y=L(l)})();var t=m.length;return e}function u(a){var m=[],e=[];a.tripleQuotedStrings?m.push(["str",/^(?:'''(?:[^'\\]|\\[\S\s]|''?(?=[^']))*(?:'''|$)|"""(?:[^"\\]|\\[\S\s]|""?(?=[^"]))*(?:"""|$)|'(?:[^'\\]|\\[\S\s])*(?:'|$)|"(?:[^"\\]|\\[\S\s])*(?:"|$))/,q,"'\""]):a.multiLineStrings?m.push(["str",/^(?:'(?:[^'\\]|\\[\S\s])*(?:'|$)|"(?:[^"\\]|\\[\S\s])*(?:"|$)|`(?:[^\\`]|\\[\S\s])*(?:`|$))/,
|
||||||
|
q,"'\"`"]):m.push(["str",/^(?:'(?:[^\n\r'\\]|\\.)*(?:'|$)|"(?:[^\n\r"\\]|\\.)*(?:"|$))/,q,"\"'"]);a.verbatimStrings&&e.push(["str",/^@"(?:[^"]|"")*(?:"|$)/,q]);var h=a.hashComments;h&&(a.cStyleComments?(h>1?m.push(["com",/^#(?:##(?:[^#]|#(?!##))*(?:###|$)|.*)/,q,"#"]):m.push(["com",/^#(?:(?:define|elif|else|endif|error|ifdef|include|ifndef|line|pragma|undef|warning)\b|[^\n\r]*)/,q,"#"]),e.push(["str",/^<(?:(?:(?:\.\.\/)*|\/?)(?:[\w-]+(?:\/[\w-]+)+)?[\w-]+\.h|[a-z]\w*)>/,q])):m.push(["com",/^#[^\n\r]*/,
|
||||||
|
q,"#"]));a.cStyleComments&&(e.push(["com",/^\/\/[^\n\r]*/,q]),e.push(["com",/^\/\*[\S\s]*?(?:\*\/|$)/,q]));a.regexLiterals&&e.push(["lang-regex",/^(?:^^\.?|[!+-]|!=|!==|#|%|%=|&|&&|&&=|&=|\(|\*|\*=|\+=|,|-=|->|\/|\/=|:|::|;|<|<<|<<=|<=|=|==|===|>|>=|>>|>>=|>>>|>>>=|[?@[^]|\^=|\^\^|\^\^=|{|\||\|=|\|\||\|\|=|~|break|case|continue|delete|do|else|finally|instanceof|return|throw|try|typeof)\s*(\/(?=[^*/])(?:[^/[\\]|\\[\S\s]|\[(?:[^\\\]]|\\[\S\s])*(?:]|$))+\/)/]);(h=a.types)&&e.push(["typ",h]);a=(""+a.keywords).replace(/^ | $/g,
|
||||||
|
"");a.length&&e.push(["kwd",RegExp("^(?:"+a.replace(/[\s,]+/g,"|")+")\\b"),q]);m.push(["pln",/^\s+/,q," \r\n\t\xa0"]);e.push(["lit",/^@[$_a-z][\w$@]*/i,q],["typ",/^(?:[@_]?[A-Z]+[a-z][\w$@]*|\w+_t\b)/,q],["pln",/^[$_a-z][\w$@]*/i,q],["lit",/^(?:0x[\da-f]+|(?:\d(?:_\d+)*\d*(?:\.\d*)?|\.\d\+)(?:e[+-]?\d+)?)[a-z]*/i,q,"0123456789"],["pln",/^\\[\S\s]?/,q],["pun",/^.[^\s\w"-$'./@\\`]*/,q]);return x(m,e)}function D(a,m){function e(a){switch(a.nodeType){case 1:if(k.test(a.className))break;if("BR"===a.nodeName)h(a),
|
||||||
|
a.parentNode&&a.parentNode.removeChild(a);else for(a=a.firstChild;a;a=a.nextSibling)e(a);break;case 3:case 4:if(p){var b=a.nodeValue,d=b.match(t);if(d){var c=b.substring(0,d.index);a.nodeValue=c;(b=b.substring(d.index+d[0].length))&&a.parentNode.insertBefore(s.createTextNode(b),a.nextSibling);h(a);c||a.parentNode.removeChild(a)}}}}function h(a){function b(a,d){var e=d?a.cloneNode(!1):a,f=a.parentNode;if(f){var f=b(f,1),g=a.nextSibling;f.appendChild(e);for(var h=g;h;h=g)g=h.nextSibling,f.appendChild(h)}return e}
|
||||||
|
for(;!a.nextSibling;)if(a=a.parentNode,!a)return;for(var a=b(a.nextSibling,0),e;(e=a.parentNode)&&e.nodeType===1;)a=e;d.push(a)}var k=/(?:^|\s)nocode(?:\s|$)/,t=/\r\n?|\n/,s=a.ownerDocument,l;a.currentStyle?l=a.currentStyle.whiteSpace:window.getComputedStyle&&(l=s.defaultView.getComputedStyle(a,q).getPropertyValue("white-space"));var p=l&&"pre"===l.substring(0,3);for(l=s.createElement("LI");a.firstChild;)l.appendChild(a.firstChild);for(var d=[l],g=0;g<d.length;++g)e(d[g]);m===(m|0)&&d[0].setAttribute("value",
|
||||||
|
m);var r=s.createElement("OL");r.className="linenums";for(var n=Math.max(0,m-1|0)||0,g=0,z=d.length;g<z;++g)l=d[g],l.className="L"+(g+n)%10,l.firstChild||l.appendChild(s.createTextNode("\xa0")),r.appendChild(l);a.appendChild(r)}function k(a,m){for(var e=m.length;--e>=0;){var h=m[e];A.hasOwnProperty(h)?window.console&&console.warn("cannot override language handler %s",h):A[h]=a}}function C(a,m){if(!a||!A.hasOwnProperty(a))a=/^\s*</.test(m)?"default-markup":"default-code";return A[a]}function E(a){var m=
|
||||||
|
a.g;try{var e=M(a.h),h=e.a;a.a=h;a.c=e.c;a.d=0;C(m,h)(a);var k=/\bMSIE\b/.test(navigator.userAgent),m=/\n/g,t=a.a,s=t.length,e=0,l=a.c,p=l.length,h=0,d=a.e,g=d.length,a=0;d[g]=s;var r,n;for(n=r=0;n<g;)d[n]!==d[n+2]?(d[r++]=d[n++],d[r++]=d[n++]):n+=2;g=r;for(n=r=0;n<g;){for(var z=d[n],f=d[n+1],b=n+2;b+2<=g&&d[b+1]===f;)b+=2;d[r++]=z;d[r++]=f;n=b}for(d.length=r;h<p;){var o=l[h+2]||s,c=d[a+2]||s,b=Math.min(o,c),i=l[h+1],j;if(i.nodeType!==1&&(j=t.substring(e,b))){k&&(j=j.replace(m,"\r"));i.nodeValue=
|
||||||
|
j;var u=i.ownerDocument,v=u.createElement("SPAN");v.className=d[a+1];var x=i.parentNode;x.replaceChild(v,i);v.appendChild(i);e<o&&(l[h+1]=i=u.createTextNode(t.substring(b,o)),x.insertBefore(i,v.nextSibling))}e=b;e>=o&&(h+=2);e>=c&&(a+=2)}}catch(w){"console"in window&&console.log(w&&w.stack?w.stack:w)}}var v=["break,continue,do,else,for,if,return,while"],w=[[v,"auto,case,char,const,default,double,enum,extern,float,goto,int,long,register,short,signed,sizeof,static,struct,switch,typedef,union,unsigned,void,volatile"],
|
||||||
|
"catch,class,delete,false,import,new,operator,private,protected,public,this,throw,true,try,typeof"],F=[w,"alignof,align_union,asm,axiom,bool,concept,concept_map,const_cast,constexpr,decltype,dynamic_cast,explicit,export,friend,inline,late_check,mutable,namespace,nullptr,reinterpret_cast,static_assert,static_cast,template,typeid,typename,using,virtual,where"],G=[w,"abstract,boolean,byte,extends,final,finally,implements,import,instanceof,null,native,package,strictfp,super,synchronized,throws,transient"],
|
||||||
|
H=[G,"as,base,by,checked,decimal,delegate,descending,dynamic,event,fixed,foreach,from,group,implicit,in,interface,internal,into,is,lock,object,out,override,orderby,params,partial,readonly,ref,sbyte,sealed,stackalloc,string,select,uint,ulong,unchecked,unsafe,ushort,var"],w=[w,"debugger,eval,export,function,get,null,set,undefined,var,with,Infinity,NaN"],I=[v,"and,as,assert,class,def,del,elif,except,exec,finally,from,global,import,in,is,lambda,nonlocal,not,or,pass,print,raise,try,with,yield,False,True,None"],
|
||||||
|
J=[v,"alias,and,begin,case,class,def,defined,elsif,end,ensure,false,in,module,next,nil,not,or,redo,rescue,retry,self,super,then,true,undef,unless,until,when,yield,BEGIN,END"],v=[v,"case,done,elif,esac,eval,fi,function,in,local,set,then,until"],K=/^(DIR|FILE|vector|(de|priority_)?queue|list|stack|(const_)?iterator|(multi)?(set|map)|bitset|u?(int|float)\d*)/,N=/\S/,O=u({keywords:[F,H,w,"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END"+
|
||||||
|
I,J,v],hashComments:!0,cStyleComments:!0,multiLineStrings:!0,regexLiterals:!0}),A={};k(O,["default-code"]);k(x([],[["pln",/^[^<?]+/],["dec",/^<!\w[^>]*(?:>|$)/],["com",/^<\!--[\S\s]*?(?:--\>|$)/],["lang-",/^<\?([\S\s]+?)(?:\?>|$)/],["lang-",/^<%([\S\s]+?)(?:%>|$)/],["pun",/^(?:<[%?]|[%?]>)/],["lang-",/^<xmp\b[^>]*>([\S\s]+?)<\/xmp\b[^>]*>/i],["lang-js",/^<script\b[^>]*>([\S\s]*?)(<\/script\b[^>]*>)/i],["lang-css",/^<style\b[^>]*>([\S\s]*?)(<\/style\b[^>]*>)/i],["lang-in.tag",/^(<\/?[a-z][^<>]*>)/i]]),
|
||||||
|
["default-markup","htm","html","mxml","xhtml","xml","xsl"]);k(x([["pln",/^\s+/,q," \t\r\n"],["atv",/^(?:"[^"]*"?|'[^']*'?)/,q,"\"'"]],[["tag",/^^<\/?[a-z](?:[\w-.:]*\w)?|\/?>$/i],["atn",/^(?!style[\s=]|on)[a-z](?:[\w:-]*\w)?/i],["lang-uq.val",/^=\s*([^\s"'>]*(?:[^\s"'/>]|\/(?=\s)))/],["pun",/^[/<->]+/],["lang-js",/^on\w+\s*=\s*"([^"]+)"/i],["lang-js",/^on\w+\s*=\s*'([^']+)'/i],["lang-js",/^on\w+\s*=\s*([^\s"'>]+)/i],["lang-css",/^style\s*=\s*"([^"]+)"/i],["lang-css",/^style\s*=\s*'([^']+)'/i],["lang-css",
|
||||||
|
/^style\s*=\s*([^\s"'>]+)/i]]),["in.tag"]);k(x([],[["atv",/^[\S\s]+/]]),["uq.val"]);k(u({keywords:F,hashComments:!0,cStyleComments:!0,types:K}),["c","cc","cpp","cxx","cyc","m"]);k(u({keywords:"null,true,false"}),["json"]);k(u({keywords:H,hashComments:!0,cStyleComments:!0,verbatimStrings:!0,types:K}),["cs"]);k(u({keywords:G,cStyleComments:!0}),["java"]);k(u({keywords:v,hashComments:!0,multiLineStrings:!0}),["bsh","csh","sh"]);k(u({keywords:I,hashComments:!0,multiLineStrings:!0,tripleQuotedStrings:!0}),
|
||||||
|
["cv","py"]);k(u({keywords:"caller,delete,die,do,dump,elsif,eval,exit,foreach,for,goto,if,import,last,local,my,next,no,our,print,package,redo,require,sub,undef,unless,until,use,wantarray,while,BEGIN,END",hashComments:!0,multiLineStrings:!0,regexLiterals:!0}),["perl","pl","pm"]);k(u({keywords:J,hashComments:!0,multiLineStrings:!0,regexLiterals:!0}),["rb"]);k(u({keywords:w,cStyleComments:!0,regexLiterals:!0}),["js"]);k(u({keywords:"all,and,by,catch,class,else,extends,false,finally,for,if,in,is,isnt,loop,new,no,not,null,of,off,on,or,return,super,then,true,try,unless,until,when,while,yes",
|
||||||
|
hashComments:3,cStyleComments:!0,multilineStrings:!0,tripleQuotedStrings:!0,regexLiterals:!0}),["coffee"]);k(x([],[["str",/^[\S\s]+/]]),["regex"]);window.prettyPrintOne=function(a,m,e){var h=document.createElement("PRE");h.innerHTML=a;e&&D(h,e);E({g:m,i:e,h:h});return h.innerHTML};window.prettyPrint=function(a){function m(){for(var e=window.PR_SHOULD_USE_CONTINUATION?l.now()+250:Infinity;p<h.length&&l.now()<e;p++){var n=h[p],k=n.className;if(k.indexOf("prettyprint")>=0){var k=k.match(g),f,b;if(b=
|
||||||
|
!k){b=n;for(var o=void 0,c=b.firstChild;c;c=c.nextSibling)var i=c.nodeType,o=i===1?o?b:c:i===3?N.test(c.nodeValue)?b:o:o;b=(f=o===b?void 0:o)&&"CODE"===f.tagName}b&&(k=f.className.match(g));k&&(k=k[1]);b=!1;for(o=n.parentNode;o;o=o.parentNode)if((o.tagName==="pre"||o.tagName==="code"||o.tagName==="xmp")&&o.className&&o.className.indexOf("prettyprint")>=0){b=!0;break}b||((b=(b=n.className.match(/\blinenums\b(?::(\d+))?/))?b[1]&&b[1].length?+b[1]:!0:!1)&&D(n,b),d={g:k,h:n,i:b},E(d))}}p<h.length?setTimeout(m,
|
||||||
|
250):a&&a()}for(var e=[document.getElementsByTagName("pre"),document.getElementsByTagName("code"),document.getElementsByTagName("xmp")],h=[],k=0;k<e.length;++k)for(var t=0,s=e[k].length;t<s;++t)h.push(e[k][t]);var e=q,l=Date;l.now||(l={now:function(){return+new Date}});var p=0,d,g=/\blang(?:uage)?-([\w.]+)(?!\S)/;m()};window.PR={createSimpleLexer:x,registerLangHandler:k,sourceDecorator:u,PR_ATTRIB_NAME:"atn",PR_ATTRIB_VALUE:"atv",PR_COMMENT:"com",PR_DECLARATION:"dec",PR_KEYWORD:"kwd",PR_LITERAL:"lit",
|
||||||
|
PR_NOCODE:"nocode",PR_PLAIN:"pln",PR_PUNCTUATION:"pun",PR_SOURCE:"src",PR_STRING:"str",PR_TAG:"tag",PR_TYPE:"typ"}})();
|
|
@ -0,0 +1,3 @@
|
||||||
|
{% extends "api/base.html" %}
|
||||||
|
|
||||||
|
{# Override this template in your own templates directory to customize #}
|
|
@ -0,0 +1,237 @@
|
||||||
|
{% load url from future %}
|
||||||
|
{% load api %}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
{% block head %}
|
||||||
|
|
||||||
|
{% block meta %}
|
||||||
|
<meta http-equiv="Content-Type" content="text/html; charset=utf-8"/>
|
||||||
|
<meta name="robots" content="NONE,NOARCHIVE" />
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<title>{% block title %}Taiga API REST{% endblock %}</title>
|
||||||
|
|
||||||
|
{% block style %}
|
||||||
|
{% block bootstrap_theme %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static "api/css/bootstrap.min.css" %}"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static "api/css/bootstrap-tweaks.css" %}"/>
|
||||||
|
{% endblock %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static "api/css/prettify.css" %}"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static "api/css/default.css" %}"/>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="{% block bodyclass %}{% endblock %} container">
|
||||||
|
|
||||||
|
<div class="wrapper">
|
||||||
|
|
||||||
|
{% block navbar %}
|
||||||
|
<div class="navbar {% block bootstrap_navbar_variant %}navbar-inverse{% endblock %}">
|
||||||
|
<div class="navbar-inner">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<span href="/">
|
||||||
|
{% block branding %}
|
||||||
|
<a class='brand' rel="nofollow" href='https://taiga.io'>
|
||||||
|
Taiga API REST
|
||||||
|
</a>
|
||||||
|
{% endblock %}
|
||||||
|
</span>
|
||||||
|
<ul class="nav pull-right">
|
||||||
|
{% block userlinks %}
|
||||||
|
{% if user.is_authenticated %}
|
||||||
|
<li class="dropdown">
|
||||||
|
<a href="#" class="dropdown-toggle" data-toggle="dropdown">
|
||||||
|
{{ user }}
|
||||||
|
<b class="caret"></b>
|
||||||
|
</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li>{% optional_logout request %}</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li>{% optional_login request %}</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<ul class="breadcrumb">
|
||||||
|
{% for breadcrumb_name, breadcrumb_url in breadcrumblist %}
|
||||||
|
<li>
|
||||||
|
<a href="{{ breadcrumb_url }}" {% if forloop.last %}class="active"{% endif %}>{{ breadcrumb_name }}</a> {% if not forloop.last %}<span class="divider">›</span>{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div id="content">
|
||||||
|
|
||||||
|
{% if 'GET' in allowed_methods %}
|
||||||
|
<form id="get-form" class="pull-right">
|
||||||
|
<fieldset>
|
||||||
|
<div class="btn-group format-selection">
|
||||||
|
<a class="btn btn-primary js-tooltip" href='{{ request.get_full_path }}' rel="nofollow" title="Make a GET request on the {{ name }} resource">GET</a>
|
||||||
|
|
||||||
|
<button class="btn btn-primary dropdown-toggle js-tooltip" data-toggle="dropdown" title="Specify a format for the GET request">
|
||||||
|
<span class="caret"></span>
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
{% for format in available_formats %}
|
||||||
|
<li>
|
||||||
|
<a class="js-tooltip format-option" href='{% add_query_param request api_settings.URL_FORMAT_OVERRIDE format %}' rel="nofollow" title="Make a GET request on the {{ name }} resource with the format set to `{{ format }}`">{{ format }}</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if options_form %}
|
||||||
|
<form class="button-form" action="{{ request.get_full_path }}" method="POST" class="pull-right">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="{{ api_settings.FORM_METHOD_OVERRIDE }}" value="OPTIONS" />
|
||||||
|
<button class="btn btn-primary js-tooltip" title="Make an OPTIONS request on the {{ name }} resource">OPTIONS</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if delete_form %}
|
||||||
|
<form class="button-form" action="{{ request.get_full_path }}" method="POST" class="pull-right">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input type="hidden" name="{{ api_settings.FORM_METHOD_OVERRIDE }}" value="DELETE" />
|
||||||
|
<button class="btn btn-danger js-tooltip" title="Make a DELETE request on the {{ name }} resource">DELETE</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="content-main">
|
||||||
|
<div class="page-header"><h1>{{ name }}</h1></div>
|
||||||
|
{% block description %}
|
||||||
|
{{ description }}
|
||||||
|
{% endblock %}
|
||||||
|
<div class="request-info" style="clear: both" >
|
||||||
|
<pre class="prettyprint"><b>{{ request.method }}</b> {{ request.get_full_path }}</pre>
|
||||||
|
</div>
|
||||||
|
<div class="response-info">
|
||||||
|
<pre class="prettyprint"><div class="meta nocode"><b>HTTP {{ response.status_code }} {{ response.status_text }}</b>{% autoescape off %}
|
||||||
|
{% for key, val in response_headers.items %}<b>{{ key }}:</b> <span class="lit">{{ val|break_long_headers|urlize_quoted_links }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>{{ content|urlize_quoted_links }}</pre>{% endautoescape %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if display_edit_forms %}
|
||||||
|
|
||||||
|
{% if post_form or raw_data_post_form %}
|
||||||
|
<div {% if post_form %}class="tabbable"{% endif %}>
|
||||||
|
{% if post_form %}
|
||||||
|
<ul class="nav nav-tabs form-switcher">
|
||||||
|
<li><a name='html-tab' href="#object-form" data-toggle="tab">HTML form</a></li>
|
||||||
|
<li><a name='raw-tab' href="#generic-content-form" data-toggle="tab">Raw data</a></li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
<div class="well tab-content">
|
||||||
|
{% if post_form %}
|
||||||
|
<div class="tab-pane" id="object-form">
|
||||||
|
{% with form=post_form %}
|
||||||
|
<form action="{{ request.get_full_path }}" method="POST" enctype="multipart/form-data" class="form-horizontal">
|
||||||
|
<fieldset>
|
||||||
|
{{ post_form }}
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary" title="Make a POST request on the {{ name }} resource">POST</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div {% if post_form %}class="tab-pane"{% endif %} id="generic-content-form">
|
||||||
|
{% with form=raw_data_post_form %}
|
||||||
|
<form action="{{ request.get_full_path }}" method="POST" class="form-horizontal">
|
||||||
|
<fieldset>
|
||||||
|
{% include "api/raw_data_form.html" %}
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary" title="Make a POST request on the {{ name }} resource">POST</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if put_form or raw_data_put_form or raw_data_patch_form %}
|
||||||
|
<div {% if put_form %}class="tabbable"{% endif %}>
|
||||||
|
{% if put_form %}
|
||||||
|
<ul class="nav nav-tabs form-switcher">
|
||||||
|
<li><a name='html-tab' href="#object-form" data-toggle="tab">HTML form</a></li>
|
||||||
|
<li><a name='raw-tab' href="#generic-content-form" data-toggle="tab">Raw data</a></li>
|
||||||
|
</ul>
|
||||||
|
{% endif %}
|
||||||
|
<div class="well tab-content">
|
||||||
|
{% if put_form %}
|
||||||
|
<div class="tab-pane" id="object-form">
|
||||||
|
<form action="{{ request.get_full_path }}" method="POST" enctype="multipart/form-data" class="form-horizontal">
|
||||||
|
<fieldset>
|
||||||
|
{{ put_form }}
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary js-tooltip" name="{{ api_settings.FORM_METHOD_OVERRIDE }}" value="PUT" title="Make a PUT request on the {{ name }} resource">PUT</button>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div {% if put_form %}class="tab-pane"{% endif %} id="generic-content-form">
|
||||||
|
{% with form=raw_data_put_or_patch_form %}
|
||||||
|
<form action="{{ request.get_full_path }}" method="POST" class="form-horizontal">
|
||||||
|
<fieldset>
|
||||||
|
{% include "api/raw_data_form.html" %}
|
||||||
|
<div class="form-actions">
|
||||||
|
{% if raw_data_put_form %}
|
||||||
|
<button class="btn btn-primary js-tooltip" name="{{ api_settings.FORM_METHOD_OVERRIDE }}" value="PUT" title="Make a PUT request on the {{ name }} resource">PUT</button>
|
||||||
|
{% endif %}
|
||||||
|
{% if raw_data_patch_form %}
|
||||||
|
<button class="btn btn-primary js-tooltip" name="{{ api_settings.FORM_METHOD_OVERRIDE }}" value="PATCH" title="Make a PATCH request on the {{ name }} resource">PATCH</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
{% endwith %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- END content-main -->
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- END Content -->
|
||||||
|
|
||||||
|
<div id="push"></div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div><!-- ./wrapper -->
|
||||||
|
|
||||||
|
{% block footer %}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block script %}
|
||||||
|
<script src="{% static "api/js/jquery-1.8.1-min.js" %}"></script>
|
||||||
|
<script src="{% static "api/js/bootstrap.min.js" %}"></script>
|
||||||
|
<script src="{% static "api/js/prettify-min.js" %}"></script>
|
||||||
|
<script src="{% static "api/js/default.js" %}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,15 @@
|
||||||
|
{% load api %}
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
{% for field in form.fields.values %}
|
||||||
|
{% if not field.read_only %}
|
||||||
|
<div class="control-group {% if field.errors %}error{% endif %}">
|
||||||
|
{{ field.label_tag|add_class:"control-label" }}
|
||||||
|
<div class="controls">
|
||||||
|
{{ field.widget_html }}
|
||||||
|
{% if field.help_text %}<span class="help-block">{{ field.help_text }}</span>{% endif %}
|
||||||
|
{% for error in field.errors %}<span class="help-block">{{ error }}</span>{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
|
@ -0,0 +1,3 @@
|
||||||
|
{% extends "api/login_base.html" %}
|
||||||
|
|
||||||
|
{# Override this template in your own templates directory to customize #}
|
|
@ -0,0 +1,53 @@
|
||||||
|
{% load url from future %}
|
||||||
|
{% load api %}
|
||||||
|
<html>
|
||||||
|
|
||||||
|
<head>
|
||||||
|
{% block style %}
|
||||||
|
{% block bootstrap_theme %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static "api/css/bootstrap.min.css" %}"/>
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static "api/css/bootstrap-tweaks.css" %}"/>
|
||||||
|
{% endblock %}
|
||||||
|
<link rel="stylesheet" type="text/css" href="{% static "api/css/default.css" %}"/>
|
||||||
|
{% endblock %}
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body class="container">
|
||||||
|
|
||||||
|
<div class="container-fluid" style="margin-top: 30px">
|
||||||
|
<div class="row-fluid">
|
||||||
|
<div class="well" style="width: 320px; margin-left: auto; margin-right: auto">
|
||||||
|
<div class="row-fluid">
|
||||||
|
<div>
|
||||||
|
{% block branding %}<h3 style="margin: 0 0 20px;">Taiga API REST</h3>{% endblock %}
|
||||||
|
</div>
|
||||||
|
</div><!-- /row fluid -->
|
||||||
|
|
||||||
|
<div class="row-fluid">
|
||||||
|
<div>
|
||||||
|
<form action="{% url 'api:login' %}" class=" form-inline" method="post">
|
||||||
|
{% csrf_token %}
|
||||||
|
<div id="div_id_username" class="clearfix control-group">
|
||||||
|
<div class="controls">
|
||||||
|
<Label class="span4">Username:</label>
|
||||||
|
<input style="height: 25px" type="text" name="username" maxlength="100" autocapitalize="off" autocorrect="off" class="textinput textInput" id="id_username">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="div_id_password" class="clearfix control-group">
|
||||||
|
<div class="controls">
|
||||||
|
<Label class="span4">Password:</label>
|
||||||
|
<input style="height: 25px" type="password" name="password" maxlength="100" autocapitalize="off" autocorrect="off" class="textinput textInput" id="id_password">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="next" value="{{ next }}" />
|
||||||
|
<div class="form-actions-no-box">
|
||||||
|
<input type="submit" name="submit" value="Log in" class="btn btn-primary" id="submit-id-submit">
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div><!-- /.row-fluid -->
|
||||||
|
</div><!--/.well-->
|
||||||
|
</div><!-- /.row-fluid -->
|
||||||
|
</div><!-- /.container-fluid -->
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,12 @@
|
||||||
|
{% load api %}
|
||||||
|
{% csrf_token %}
|
||||||
|
{{ form.non_field_errors }}
|
||||||
|
{% for field in form %}
|
||||||
|
<div class="control-group">
|
||||||
|
{{ field.label_tag|add_class:"control-label" }}
|
||||||
|
<div class="controls">
|
||||||
|
{{ field }}
|
||||||
|
<span class="help-block">{{ field.help_text }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
|
@ -0,0 +1,233 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
from django.core.urlresolvers import reverse, NoReverseMatch
|
||||||
|
from django.contrib.staticfiles.templatetags.staticfiles import StaticFilesNode
|
||||||
|
from django.http import QueryDict
|
||||||
|
from django.utils.encoding import iri_to_uri
|
||||||
|
from django.utils.html import escape
|
||||||
|
from django.utils.safestring import SafeData, mark_safe
|
||||||
|
from django.utils import six
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
from django.utils.html import smart_urlquote
|
||||||
|
|
||||||
|
from urllib import parse as urlparse
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
register = template.Library()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@register.tag("static")
|
||||||
|
def do_static(parser, token):
|
||||||
|
return StaticFilesNode.handle_token(parser, token)
|
||||||
|
|
||||||
|
|
||||||
|
def replace_query_param(url, key, val):
|
||||||
|
"""
|
||||||
|
Given a URL and a key/val pair, set or replace an item in the query
|
||||||
|
parameters of the URL, and return the new URL.
|
||||||
|
"""
|
||||||
|
(scheme, netloc, path, query, fragment) = urlparse.urlsplit(url)
|
||||||
|
query_dict = QueryDict(query).copy()
|
||||||
|
query_dict[key] = val
|
||||||
|
query = query_dict.urlencode()
|
||||||
|
return urlparse.urlunsplit((scheme, netloc, path, query, fragment))
|
||||||
|
|
||||||
|
|
||||||
|
# Regex for adding classes to html snippets
|
||||||
|
class_re = re.compile(r'(?<=class=["\'])(.*)(?=["\'])')
|
||||||
|
|
||||||
|
|
||||||
|
# And the template tags themselves...
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def optional_login(request):
|
||||||
|
"""
|
||||||
|
Include a login snippet if REST framework's login view is in the URLconf.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
login_url = reverse("api:login")
|
||||||
|
except NoReverseMatch:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
snippet = "<a href='%s?next=%s'>Log in</a>" % (login_url, request.path)
|
||||||
|
return snippet
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def optional_logout(request):
|
||||||
|
"""
|
||||||
|
Include a logout snippet if REST framework's logout view is in the URLconf.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logout_url = reverse("api:logout")
|
||||||
|
except NoReverseMatch:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
snippet = "<a href='%s?next=%s'>Log out</a>" % (logout_url, request.path)
|
||||||
|
return snippet
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def add_query_param(request, key, val):
|
||||||
|
"""
|
||||||
|
Add a query parameter to the current request url, and return the new url.
|
||||||
|
"""
|
||||||
|
iri = request.get_full_path()
|
||||||
|
uri = iri_to_uri(iri)
|
||||||
|
return replace_query_param(uri, key, val)
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def add_class(value, css_class):
|
||||||
|
"""
|
||||||
|
http://stackoverflow.com/questions/4124220/django-adding-css-classes-when-rendering-form-fields-in-a-template
|
||||||
|
|
||||||
|
Inserts classes into template variables that contain HTML tags,
|
||||||
|
useful for modifying forms without needing to change the Form objects.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
|
||||||
|
{{ field.label_tag|add_class:"control-label" }}
|
||||||
|
|
||||||
|
In the case of REST Framework, the filter is used to add Bootstrap-specific
|
||||||
|
classes to the forms.
|
||||||
|
"""
|
||||||
|
html = six.text_type(value)
|
||||||
|
match = class_re.search(html)
|
||||||
|
if match:
|
||||||
|
m = re.search(r"^%s$|^%s\s|\s%s\s|\s%s$" % (css_class, css_class,
|
||||||
|
css_class, css_class),
|
||||||
|
match.group(1))
|
||||||
|
if not m:
|
||||||
|
return mark_safe(class_re.sub(match.group(1) + " " + css_class,
|
||||||
|
html))
|
||||||
|
else:
|
||||||
|
return mark_safe(html.replace(">", ' class="%s">' % css_class, 1))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
# Bunch of stuff cloned from urlize
|
||||||
|
TRAILING_PUNCTUATION = [".", ",", ":", ";", ".)", "\"", "'"]
|
||||||
|
WRAPPING_PUNCTUATION = [("(", ")"), ("<", ">"), ("[", "]"), ("<", ">"),
|
||||||
|
("\"", "\""), ("'", "'")]
|
||||||
|
word_split_re = re.compile(r"(\s+)")
|
||||||
|
simple_url_re = re.compile(r"^https?://\[?\w", re.IGNORECASE)
|
||||||
|
simple_url_2_re = re.compile(r"^www\.|^(?!http)\w[^@]+\.(com|edu|gov|int|mil|net|org)$", re.IGNORECASE)
|
||||||
|
simple_email_re = re.compile(r"^\S+@\S+\.\S+$")
|
||||||
|
|
||||||
|
|
||||||
|
def smart_urlquote_wrapper(matched_url):
|
||||||
|
"""
|
||||||
|
Simple wrapper for smart_urlquote. ValueError("Invalid IPv6 URL") can
|
||||||
|
be raised here, see issue #1386
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return smart_urlquote(matched_url)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def urlize_quoted_links(text, trim_url_limit=None, nofollow=True, autoescape=True):
|
||||||
|
"""
|
||||||
|
Converts any URLs in text into clickable links.
|
||||||
|
|
||||||
|
Works on http://, https://, www. links, and also on links ending in one of
|
||||||
|
the original seven gTLDs (.com, .edu, .gov, .int, .mil, .net, and .org).
|
||||||
|
Links can have trailing punctuation (periods, commas, close-parens) and
|
||||||
|
leading punctuation (opening parens) and it"ll still do the right thing.
|
||||||
|
|
||||||
|
If trim_url_limit is not None, the URLs in link text longer than this limit
|
||||||
|
will truncated to trim_url_limit-3 characters and appended with an elipsis.
|
||||||
|
|
||||||
|
If nofollow is True, the URLs in link text will get a rel="nofollow"
|
||||||
|
attribute.
|
||||||
|
|
||||||
|
If autoescape is True, the link text and URLs will get autoescaped.
|
||||||
|
"""
|
||||||
|
trim_url = lambda x, limit=trim_url_limit: limit is not None and (len(x) > limit and ("%s..." % x[:max(0, limit - 3)])) or x
|
||||||
|
safe_input = isinstance(text, SafeData)
|
||||||
|
words = word_split_re.split(force_text(text))
|
||||||
|
for i, word in enumerate(words):
|
||||||
|
if "." in word or "@" in word or ":" in word:
|
||||||
|
# Deal with punctuation.
|
||||||
|
lead, middle, trail = "", word, ""
|
||||||
|
for punctuation in TRAILING_PUNCTUATION:
|
||||||
|
if middle.endswith(punctuation):
|
||||||
|
middle = middle[:-len(punctuation)]
|
||||||
|
trail = punctuation + trail
|
||||||
|
for opening, closing in WRAPPING_PUNCTUATION:
|
||||||
|
if middle.startswith(opening):
|
||||||
|
middle = middle[len(opening):]
|
||||||
|
lead = lead + opening
|
||||||
|
# Keep parentheses at the end only if they"re balanced.
|
||||||
|
if (middle.endswith(closing)
|
||||||
|
and middle.count(closing) == middle.count(opening) + 1):
|
||||||
|
middle = middle[:-len(closing)]
|
||||||
|
trail = closing + trail
|
||||||
|
|
||||||
|
# Make URL we want to point to.
|
||||||
|
url = None
|
||||||
|
nofollow_attr = ' rel="nofollow"' if nofollow else ""
|
||||||
|
if simple_url_re.match(middle):
|
||||||
|
url = smart_urlquote_wrapper(middle)
|
||||||
|
elif simple_url_2_re.match(middle):
|
||||||
|
url = smart_urlquote_wrapper("http://%s" % middle)
|
||||||
|
elif not ":" in middle and simple_email_re.match(middle):
|
||||||
|
local, domain = middle.rsplit("@", 1)
|
||||||
|
try:
|
||||||
|
domain = domain.encode("idna").decode("ascii")
|
||||||
|
except UnicodeError:
|
||||||
|
continue
|
||||||
|
url = "mailto:%s@%s" % (local, domain)
|
||||||
|
nofollow_attr = ""
|
||||||
|
|
||||||
|
# Make link.
|
||||||
|
if url:
|
||||||
|
trimmed = trim_url(middle)
|
||||||
|
if autoescape and not safe_input:
|
||||||
|
lead, trail = escape(lead), escape(trail)
|
||||||
|
url, trimmed = escape(url), escape(trimmed)
|
||||||
|
middle = '<a href="%s"%s>%s</a>' % (url, nofollow_attr, trimmed)
|
||||||
|
words[i] = mark_safe("%s%s%s" % (lead, middle, trail))
|
||||||
|
else:
|
||||||
|
if safe_input:
|
||||||
|
words[i] = mark_safe(word)
|
||||||
|
elif autoescape:
|
||||||
|
words[i] = escape(word)
|
||||||
|
elif safe_input:
|
||||||
|
words[i] = mark_safe(word)
|
||||||
|
elif autoescape:
|
||||||
|
words[i] = escape(word)
|
||||||
|
return "".join(words)
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def break_long_headers(header):
|
||||||
|
"""
|
||||||
|
Breaks headers longer than 160 characters (~page length)
|
||||||
|
when possible (are comma separated)
|
||||||
|
"""
|
||||||
|
if len(header) > 160 and "," in header:
|
||||||
|
header = mark_safe("<br> " + ", <br>".join(header.split(",")))
|
||||||
|
return header
|
|
@ -0,0 +1,255 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
"""
|
||||||
|
Provides various throttling policies.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.core.cache import cache as default_cache
|
||||||
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
|
from .settings import api_settings
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class BaseThrottle(object):
|
||||||
|
"""
|
||||||
|
Rate throttling of requests.
|
||||||
|
"""
|
||||||
|
def allow_request(self, request, view):
|
||||||
|
"""
|
||||||
|
Return `True` if the request should be allowed, `False` otherwise.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(".allow_request() must be overridden")
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
"""
|
||||||
|
Optionally, return a recommended number of seconds to wait before
|
||||||
|
the next request.
|
||||||
|
"""
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleRateThrottle(BaseThrottle):
|
||||||
|
"""
|
||||||
|
A simple cache implementation, that only requires `.get_cache_key()`
|
||||||
|
to be overridden.
|
||||||
|
|
||||||
|
The rate (requests / seconds) is set by a `throttle` attribute on the View
|
||||||
|
class. The attribute is a string of the form "number_of_requests/period".
|
||||||
|
|
||||||
|
Period should be one of: ("s", "sec", "m", "min", "h", "hour", "d", "day")
|
||||||
|
|
||||||
|
Previous request information used for throttling is stored in the cache.
|
||||||
|
"""
|
||||||
|
|
||||||
|
cache = default_cache
|
||||||
|
timer = time.time
|
||||||
|
cache_format = "throtte_%(scope)s_%(ident)s"
|
||||||
|
scope = None
|
||||||
|
THROTTLE_RATES = api_settings.DEFAULT_THROTTLE_RATES
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if not getattr(self, "rate", None):
|
||||||
|
self.rate = self.get_rate()
|
||||||
|
self.num_requests, self.duration = self.parse_rate(self.rate)
|
||||||
|
|
||||||
|
def get_cache_key(self, request, view):
|
||||||
|
"""
|
||||||
|
Should return a unique cache-key which can be used for throttling.
|
||||||
|
Must be overridden.
|
||||||
|
|
||||||
|
May return `None` if the request should not be throttled.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(".get_cache_key() must be overridden")
|
||||||
|
|
||||||
|
def get_rate(self):
|
||||||
|
"""
|
||||||
|
Determine the string representation of the allowed request rate.
|
||||||
|
"""
|
||||||
|
if not getattr(self, "scope", None):
|
||||||
|
msg = ("You must set either `.scope` or `.rate` for \"%s\" throttle" %
|
||||||
|
self.__class__.__name__)
|
||||||
|
raise ImproperlyConfigured(msg)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return self.THROTTLE_RATES[self.scope]
|
||||||
|
except KeyError:
|
||||||
|
msg = "No default throttle rate set for \"%s\" scope" % self.scope
|
||||||
|
raise ImproperlyConfigured(msg)
|
||||||
|
|
||||||
|
def parse_rate(self, rate):
|
||||||
|
"""
|
||||||
|
Given the request rate string, return a two tuple of:
|
||||||
|
<allowed number of requests>, <period of time in seconds>
|
||||||
|
"""
|
||||||
|
if rate is None:
|
||||||
|
return (None, None)
|
||||||
|
num, period = rate.split("/")
|
||||||
|
num_requests = int(num)
|
||||||
|
duration = {"s": 1, "m": 60, "h": 3600, "d": 86400}[period[0]]
|
||||||
|
return (num_requests, duration)
|
||||||
|
|
||||||
|
def allow_request(self, request, view):
|
||||||
|
"""
|
||||||
|
Implement the check to see if the request should be throttled.
|
||||||
|
|
||||||
|
On success calls `throttle_success`.
|
||||||
|
On failure calls `throttle_failure`.
|
||||||
|
"""
|
||||||
|
if self.rate is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.key = self.get_cache_key(request, view)
|
||||||
|
if self.key is None:
|
||||||
|
return True
|
||||||
|
|
||||||
|
self.history = self.cache.get(self.key, [])
|
||||||
|
self.now = self.timer()
|
||||||
|
|
||||||
|
# Drop any requests from the history which have now passed the
|
||||||
|
# throttle duration
|
||||||
|
while self.history and self.history[-1] <= self.now - self.duration:
|
||||||
|
self.history.pop()
|
||||||
|
if len(self.history) >= self.num_requests:
|
||||||
|
return self.throttle_failure()
|
||||||
|
return self.throttle_success()
|
||||||
|
|
||||||
|
def throttle_success(self):
|
||||||
|
"""
|
||||||
|
Inserts the current request's timestamp along with the key
|
||||||
|
into the cache.
|
||||||
|
"""
|
||||||
|
self.history.insert(0, self.now)
|
||||||
|
self.cache.set(self.key, self.history, self.duration)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def throttle_failure(self):
|
||||||
|
"""
|
||||||
|
Called when a request to the API has failed due to throttling.
|
||||||
|
"""
|
||||||
|
return False
|
||||||
|
|
||||||
|
def wait(self):
|
||||||
|
"""
|
||||||
|
Returns the recommended next request time in seconds.
|
||||||
|
"""
|
||||||
|
if self.history:
|
||||||
|
remaining_duration = self.duration - (self.now - self.history[-1])
|
||||||
|
else:
|
||||||
|
remaining_duration = self.duration
|
||||||
|
|
||||||
|
available_requests = self.num_requests - len(self.history) + 1
|
||||||
|
if available_requests <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return remaining_duration / float(available_requests)
|
||||||
|
|
||||||
|
|
||||||
|
class AnonRateThrottle(SimpleRateThrottle):
|
||||||
|
"""
|
||||||
|
Limits the rate of API calls that may be made by a anonymous users.
|
||||||
|
|
||||||
|
The IP address of the request will be used as the unique cache key.
|
||||||
|
"""
|
||||||
|
scope = "anon"
|
||||||
|
|
||||||
|
def get_cache_key(self, request, view):
|
||||||
|
if request.user.is_authenticated():
|
||||||
|
return None # Only throttle unauthenticated requests.
|
||||||
|
|
||||||
|
ident = request.META.get("HTTP_X_FORWARDED_FOR")
|
||||||
|
if ident is None:
|
||||||
|
ident = request.META.get("REMOTE_ADDR")
|
||||||
|
|
||||||
|
return self.cache_format % {
|
||||||
|
"scope": self.scope,
|
||||||
|
"ident": ident
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class UserRateThrottle(SimpleRateThrottle):
|
||||||
|
"""
|
||||||
|
Limits the rate of API calls that may be made by a given user.
|
||||||
|
|
||||||
|
The user id will be used as a unique cache key if the user is
|
||||||
|
authenticated. For anonymous requests, the IP address of the request will
|
||||||
|
be used.
|
||||||
|
"""
|
||||||
|
scope = "user"
|
||||||
|
|
||||||
|
def get_cache_key(self, request, view):
|
||||||
|
if request.user.is_authenticated():
|
||||||
|
ident = request.user.id
|
||||||
|
else:
|
||||||
|
ident = request.META.get("REMOTE_ADDR", None)
|
||||||
|
|
||||||
|
return self.cache_format % {
|
||||||
|
"scope": self.scope,
|
||||||
|
"ident": ident
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class ScopedRateThrottle(SimpleRateThrottle):
|
||||||
|
"""
|
||||||
|
Limits the rate of API calls by different amounts for various parts of
|
||||||
|
the API. Any view that has the `throttle_scope` property set will be
|
||||||
|
throttled. The unique cache key will be generated by concatenating the
|
||||||
|
user id of the request, and the scope of the view being accessed.
|
||||||
|
"""
|
||||||
|
scope_attr = "throttle_scope"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
# Override the usual SimpleRateThrottle, because we can't determine
|
||||||
|
# the rate until called by the view.
|
||||||
|
pass
|
||||||
|
|
||||||
|
def allow_request(self, request, view):
|
||||||
|
# We can only determine the scope once we"re called by the view.
|
||||||
|
self.scope = getattr(view, self.scope_attr, None)
|
||||||
|
|
||||||
|
# If a view does not have a `throttle_scope` always allow the request
|
||||||
|
if not self.scope:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Determine the allowed request rate as we normally would during
|
||||||
|
# the `__init__` call.
|
||||||
|
self.rate = self.get_rate()
|
||||||
|
self.num_requests, self.duration = self.parse_rate(self.rate)
|
||||||
|
|
||||||
|
# We can now proceed as normal.
|
||||||
|
return super(ScopedRateThrottle, self).allow_request(request, view)
|
||||||
|
|
||||||
|
def get_cache_key(self, request, view):
|
||||||
|
"""
|
||||||
|
If `view.throttle_scope` is not set, don't apply this throttle.
|
||||||
|
|
||||||
|
Otherwise generate the unique cache key by concatenating the user id
|
||||||
|
with the ".throttle_scope` property of the view.
|
||||||
|
"""
|
||||||
|
if request.user.is_authenticated():
|
||||||
|
ident = request.user.id
|
||||||
|
else:
|
||||||
|
ident = request.META.get("REMOTE_ADDR", None)
|
||||||
|
|
||||||
|
return self.cache_format % {
|
||||||
|
"scope": self.scope,
|
||||||
|
"ident": ident
|
||||||
|
}
|
|
@ -0,0 +1,82 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
|
||||||
|
from django.core.urlresolvers import RegexURLResolver
|
||||||
|
from django.conf.urls import patterns, url, include
|
||||||
|
|
||||||
|
from .settings import api_settings
|
||||||
|
|
||||||
|
|
||||||
|
def apply_suffix_patterns(urlpatterns, suffix_pattern, suffix_required):
|
||||||
|
ret = []
|
||||||
|
for urlpattern in urlpatterns:
|
||||||
|
if isinstance(urlpattern, RegexURLResolver):
|
||||||
|
# Set of included URL patterns
|
||||||
|
regex = urlpattern.regex.pattern
|
||||||
|
namespace = urlpattern.namespace
|
||||||
|
app_name = urlpattern.app_name
|
||||||
|
kwargs = urlpattern.default_kwargs
|
||||||
|
# Add in the included patterns, after applying the suffixes
|
||||||
|
patterns = apply_suffix_patterns(urlpattern.url_patterns,
|
||||||
|
suffix_pattern,
|
||||||
|
suffix_required)
|
||||||
|
ret.append(url(regex, include(patterns, namespace, app_name), kwargs))
|
||||||
|
|
||||||
|
else:
|
||||||
|
# Regular URL pattern
|
||||||
|
regex = urlpattern.regex.pattern.rstrip("$") + suffix_pattern
|
||||||
|
view = urlpattern._callback or urlpattern._callback_str
|
||||||
|
kwargs = urlpattern.default_args
|
||||||
|
name = urlpattern.name
|
||||||
|
# Add in both the existing and the new urlpattern
|
||||||
|
if not suffix_required:
|
||||||
|
ret.append(urlpattern)
|
||||||
|
ret.append(url(regex, view, kwargs, name))
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def format_suffix_patterns(urlpatterns, suffix_required=False, allowed=None):
|
||||||
|
"""
|
||||||
|
Supplement existing urlpatterns with corresponding patterns that also
|
||||||
|
include a ".format" suffix. Retains urlpattern ordering.
|
||||||
|
|
||||||
|
urlpatterns:
|
||||||
|
A list of URL patterns.
|
||||||
|
|
||||||
|
suffix_required:
|
||||||
|
If `True`, only suffixed URLs will be generated, and non-suffixed
|
||||||
|
URLs will not be used. Defaults to `False`.
|
||||||
|
|
||||||
|
allowed:
|
||||||
|
An optional tuple/list of allowed suffixes. eg ["json", "api"]
|
||||||
|
Defaults to `None`, which allows any suffix.
|
||||||
|
"""
|
||||||
|
suffix_kwarg = api_settings.FORMAT_SUFFIX_KWARG
|
||||||
|
if allowed:
|
||||||
|
if len(allowed) == 1:
|
||||||
|
allowed_pattern = allowed[0]
|
||||||
|
else:
|
||||||
|
allowed_pattern = "(%s)" % "|".join(allowed)
|
||||||
|
suffix_pattern = r"\.(?P<%s>%s)$" % (suffix_kwarg, allowed_pattern)
|
||||||
|
else:
|
||||||
|
suffix_pattern = r"\.(?P<%s>[a-z0-9]+)$" % suffix_kwarg
|
||||||
|
|
||||||
|
return apply_suffix_patterns(urlpatterns, suffix_pattern, suffix_required)
|
|
@ -0,0 +1,43 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
"""
|
||||||
|
Login and logout views for the browsable API.
|
||||||
|
|
||||||
|
Add these to your root URLconf if you're using the browsable API and
|
||||||
|
your API requires authentication.
|
||||||
|
|
||||||
|
The urls must be namespaced as 'api', and you should make sure
|
||||||
|
your authentication settings include `SessionAuthentication`.
|
||||||
|
|
||||||
|
urlpatterns = patterns('',
|
||||||
|
...
|
||||||
|
url(r'^auth', include('taiga.base.api.urls', namespace='api'))
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
from django.conf.urls import patterns
|
||||||
|
from django.conf.urls import url
|
||||||
|
|
||||||
|
|
||||||
|
template_name = {"template_name": "api/login.html"}
|
||||||
|
|
||||||
|
urlpatterns = patterns("django.contrib.auth.views",
|
||||||
|
url(r"^login/$", "login", template_name, name="login"),
|
||||||
|
url(r"^logout/$", "logout", template_name, name="logout"),
|
||||||
|
)
|
|
@ -0,0 +1,33 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
from django.http import Http404
|
||||||
|
from django.shortcuts import get_object_or_404 as _get_object_or_404
|
||||||
|
|
||||||
|
|
||||||
|
def get_object_or_404(queryset, *filter_args, **filter_kwargs):
|
||||||
|
"""
|
||||||
|
Same as Django's standard shortcut, but make sure to raise 404
|
||||||
|
if the filter_kwargs don't match the required types.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return _get_object_or_404(queryset, *filter_args, **filter_kwargs)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise Http404
|
||||||
|
|
|
@ -0,0 +1,74 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
from django.core.urlresolvers import resolve, get_script_prefix
|
||||||
|
|
||||||
|
|
||||||
|
def get_breadcrumbs(url):
|
||||||
|
"""
|
||||||
|
Given a url returns a list of breadcrumbs, which are each a
|
||||||
|
tuple of (name, url).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from taiga.base.api.settings import api_settings
|
||||||
|
from taiga.base.api.views import APIView
|
||||||
|
|
||||||
|
view_name_func = api_settings.VIEW_NAME_FUNCTION
|
||||||
|
|
||||||
|
def breadcrumbs_recursive(url, breadcrumbs_list, prefix, seen):
|
||||||
|
"""
|
||||||
|
Add tuples of (name, url) to the breadcrumbs list,
|
||||||
|
progressively chomping off parts of the url.
|
||||||
|
"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
(view, unused_args, unused_kwargs) = resolve(url)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# Check if this is a REST framework view,
|
||||||
|
# and if so add it to the breadcrumbs
|
||||||
|
cls = getattr(view, "cls", None)
|
||||||
|
if cls is not None and issubclass(cls, APIView):
|
||||||
|
# Don't list the same view twice in a row.
|
||||||
|
# Probably an optional trailing slash.
|
||||||
|
if not seen or seen[-1] != view:
|
||||||
|
suffix = getattr(view, "suffix", None)
|
||||||
|
name = view_name_func(cls, suffix)
|
||||||
|
breadcrumbs_list.insert(0, (name, prefix + url))
|
||||||
|
seen.append(view)
|
||||||
|
|
||||||
|
if url == "":
|
||||||
|
# All done
|
||||||
|
return breadcrumbs_list
|
||||||
|
|
||||||
|
elif url.endswith("/"):
|
||||||
|
# Drop trailing slash off the end and continue to try to
|
||||||
|
# resolve more breadcrumbs
|
||||||
|
url = url.rstrip("/")
|
||||||
|
return breadcrumbs_recursive(url, breadcrumbs_list, prefix, seen)
|
||||||
|
|
||||||
|
# Drop trailing non-slash off the end and continue to try to
|
||||||
|
# resolve more breadcrumbs
|
||||||
|
url = url[:url.rfind("/") + 1]
|
||||||
|
return breadcrumbs_recursive(url, breadcrumbs_list, prefix, seen)
|
||||||
|
|
||||||
|
prefix = get_script_prefix().rstrip("/")
|
||||||
|
url = url[len(prefix):]
|
||||||
|
return breadcrumbs_recursive(url, [], prefix, [])
|
|
@ -0,0 +1,81 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
"""
|
||||||
|
Helper classes for parsers.
|
||||||
|
"""
|
||||||
|
from django.db.models.query import QuerySet
|
||||||
|
from django.utils.datastructures import SortedDict
|
||||||
|
from django.utils.functional import Promise
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
|
from taiga.base.api.serializers import DictWithMetadata, SortedDictWithMetadata
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import decimal
|
||||||
|
import types
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class JSONEncoder(json.JSONEncoder):
|
||||||
|
"""
|
||||||
|
JSONEncoder subclass that knows how to encode date/time/timedelta,
|
||||||
|
decimal types, and generators.
|
||||||
|
"""
|
||||||
|
def default(self, o):
|
||||||
|
# For Date Time string spec, see ECMA 262
|
||||||
|
# http://ecma-international.org/ecma-262/5.1/#sec-15.9.1.15
|
||||||
|
if isinstance(o, Promise):
|
||||||
|
return force_text(o)
|
||||||
|
elif isinstance(o, datetime.datetime):
|
||||||
|
r = o.isoformat()
|
||||||
|
if o.microsecond:
|
||||||
|
r = r[:23] + r[26:]
|
||||||
|
if r.endswith("+00:00"):
|
||||||
|
r = r[:-6] + "Z"
|
||||||
|
return r
|
||||||
|
elif isinstance(o, datetime.date):
|
||||||
|
return o.isoformat()
|
||||||
|
elif isinstance(o, datetime.time):
|
||||||
|
if timezone and timezone.is_aware(o):
|
||||||
|
raise ValueError("JSON can't represent timezone-aware times.")
|
||||||
|
r = o.isoformat()
|
||||||
|
if o.microsecond:
|
||||||
|
r = r[:12]
|
||||||
|
return r
|
||||||
|
elif isinstance(o, datetime.timedelta):
|
||||||
|
return str(o.total_seconds())
|
||||||
|
elif isinstance(o, decimal.Decimal):
|
||||||
|
return str(o)
|
||||||
|
elif isinstance(o, QuerySet):
|
||||||
|
return list(o)
|
||||||
|
elif hasattr(o, "tolist"):
|
||||||
|
return o.tolist()
|
||||||
|
elif hasattr(o, "__getitem__"):
|
||||||
|
try:
|
||||||
|
return dict(o)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
elif hasattr(o, "__iter__"):
|
||||||
|
return [i for i in o]
|
||||||
|
return super(JSONEncoder, self).default(o)
|
||||||
|
|
||||||
|
|
||||||
|
SafeDumper = None
|
|
@ -0,0 +1,95 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
"""
|
||||||
|
Utility functions to return a formatted name and description for a given view.
|
||||||
|
"""
|
||||||
|
from django.utils.html import escape
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
|
from taiga.base.api.settings import api_settings
|
||||||
|
|
||||||
|
from textwrap import dedent
|
||||||
|
import re
|
||||||
|
|
||||||
|
# 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 <h2>.
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def remove_trailing_string(content, trailing):
|
||||||
|
"""
|
||||||
|
Strip trailing component `trailing` from `content` if it exists.
|
||||||
|
Used when generating names from view classes.
|
||||||
|
"""
|
||||||
|
if content.endswith(trailing) and content != trailing:
|
||||||
|
return content[:-len(trailing)]
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
|
def dedent(content):
|
||||||
|
"""
|
||||||
|
Remove leading indent from a block of text.
|
||||||
|
Used when generating descriptions from docstrings.
|
||||||
|
|
||||||
|
Note that python's `textwrap.dedent` doesn't quite cut it,
|
||||||
|
as it fails to dedent multiline docstrings that include
|
||||||
|
unindented text on the initial line.
|
||||||
|
"""
|
||||||
|
whitespace_counts = [len(line) - len(line.lstrip(" "))
|
||||||
|
for line in content.splitlines()[1:] if line.lstrip()]
|
||||||
|
|
||||||
|
# unindent the content if needed
|
||||||
|
if whitespace_counts:
|
||||||
|
whitespace_pattern = "^" + (" " * min(whitespace_counts))
|
||||||
|
content = re.sub(re.compile(whitespace_pattern, re.MULTILINE), "", content)
|
||||||
|
|
||||||
|
return content.strip()
|
||||||
|
|
||||||
|
def camelcase_to_spaces(content):
|
||||||
|
"""
|
||||||
|
Translate 'CamelCaseNames' to 'Camel Case Names'.
|
||||||
|
Used when generating names from view classes.
|
||||||
|
"""
|
||||||
|
camelcase_boundry = "(((?<=[a-z])[A-Z])|([A-Z](?![A-Z]|$)))"
|
||||||
|
content = re.sub(camelcase_boundry, " \\1", content).strip()
|
||||||
|
return " ".join(content.split("_")).title()
|
||||||
|
|
||||||
|
def markup_description(description):
|
||||||
|
"""
|
||||||
|
Apply HTML markup to the given description.
|
||||||
|
"""
|
||||||
|
if apply_markdown:
|
||||||
|
description = apply_markdown(description)
|
||||||
|
else:
|
||||||
|
description = escape(description).replace("\n", "<br />")
|
||||||
|
return mark_safe(description)
|
|
@ -0,0 +1,107 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
|
"""
|
||||||
|
Handling of media types, as found in HTTP Content-Type and Accept headers.
|
||||||
|
|
||||||
|
See http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7
|
||||||
|
"""
|
||||||
|
from django.http.multipartparser import parse_header
|
||||||
|
|
||||||
|
from taiga.base.api import HTTP_HEADER_ENCODING
|
||||||
|
|
||||||
|
|
||||||
|
def media_type_matches(lhs, rhs):
|
||||||
|
"""
|
||||||
|
Returns ``True`` if the media type in the first argument <= the
|
||||||
|
media type in the second argument. The media types are strings
|
||||||
|
as described by the HTTP spec.
|
||||||
|
|
||||||
|
Valid media type strings include:
|
||||||
|
|
||||||
|
'application/json; indent=4'
|
||||||
|
'application/json'
|
||||||
|
'text/*'
|
||||||
|
'*/*'
|
||||||
|
"""
|
||||||
|
lhs = _MediaType(lhs)
|
||||||
|
rhs = _MediaType(rhs)
|
||||||
|
return lhs.match(rhs)
|
||||||
|
|
||||||
|
|
||||||
|
def order_by_precedence(media_type_lst):
|
||||||
|
"""
|
||||||
|
Returns a list of sets of media type strings, ordered by precedence.
|
||||||
|
Precedence is determined by how specific a media type is:
|
||||||
|
|
||||||
|
3. 'type/subtype; param=val'
|
||||||
|
2. 'type/subtype'
|
||||||
|
1. 'type/*'
|
||||||
|
0. '*/*'
|
||||||
|
"""
|
||||||
|
ret = [set(), set(), set(), set()]
|
||||||
|
for media_type in media_type_lst:
|
||||||
|
precedence = _MediaType(media_type).precedence
|
||||||
|
ret[3 - precedence].add(media_type)
|
||||||
|
return [media_types for media_types in ret if media_types]
|
||||||
|
|
||||||
|
|
||||||
|
class _MediaType(object):
|
||||||
|
def __init__(self, media_type_str):
|
||||||
|
if media_type_str is None:
|
||||||
|
media_type_str = ''
|
||||||
|
self.orig = media_type_str
|
||||||
|
self.full_type, self.params = parse_header(media_type_str.encode(HTTP_HEADER_ENCODING))
|
||||||
|
self.main_type, sep, self.sub_type = self.full_type.partition("/")
|
||||||
|
|
||||||
|
def match(self, other):
|
||||||
|
"""Return true if this MediaType satisfies the given MediaType."""
|
||||||
|
for key in self.params.keys():
|
||||||
|
if key != "q" and other.params.get(key, None) != self.params.get(key, None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.sub_type != "*" and other.sub_type != "*" and other.sub_type != self.sub_type:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if self.main_type != "*" and other.main_type != "*" and other.main_type != self.main_type:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def precedence(self):
|
||||||
|
"""
|
||||||
|
Return a precedence level from 0-3 for the media type given how specific it is.
|
||||||
|
"""
|
||||||
|
if self.main_type == "*":
|
||||||
|
return 0
|
||||||
|
elif self.sub_type == "*":
|
||||||
|
return 1
|
||||||
|
elif not self.params or self.params.keys() == ["q"]:
|
||||||
|
return 2
|
||||||
|
return 3
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return unicode(self).encode("utf-8")
|
||||||
|
|
||||||
|
def __unicode__(self):
|
||||||
|
ret = "%s/%s" % (self.main_type, self.sub_type)
|
||||||
|
for key, val in self.params.items():
|
||||||
|
ret += "; %s=%s" % (key, val)
|
||||||
|
return ret
|
|
@ -19,25 +19,29 @@
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
from django.core.exceptions import PermissionDenied
|
from django.core.exceptions import PermissionDenied
|
||||||
from django.http import Http404, HttpResponse
|
from django.http import Http404, HttpResponse
|
||||||
from django.utils.datastructures import SortedDict
|
from django.http.response import HttpResponseBase
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
|
from django.views.defaults import server_error
|
||||||
|
from django.views.generic import View
|
||||||
|
from django.utils.datastructures import SortedDict
|
||||||
|
from django.utils.encoding import smart_text
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from rest_framework import status, exceptions
|
from .request import Request
|
||||||
from rest_framework.compat import smart_text, HttpResponseBase, View
|
from .settings import api_settings
|
||||||
from rest_framework.request import Request
|
from .utils import formatting
|
||||||
from rest_framework.settings import api_settings
|
|
||||||
from rest_framework.utils import formatting
|
|
||||||
|
|
||||||
|
from taiga.base import status
|
||||||
|
from taiga.base import exceptions
|
||||||
from taiga.base.response import Response
|
from taiga.base.response import Response
|
||||||
from taiga.base.response import Ok
|
from taiga.base.response import Ok
|
||||||
from taiga.base.response import NotFound
|
from taiga.base.response import NotFound
|
||||||
from taiga.base.response import Forbidden
|
from taiga.base.response import Forbidden
|
||||||
from taiga.base.utils.iterators import as_tuple
|
from taiga.base.utils.iterators import as_tuple
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.views.defaults import server_error
|
|
||||||
|
|
||||||
|
|
||||||
def get_view_name(view_cls, suffix=None):
|
def get_view_name(view_cls, suffix=None):
|
||||||
|
@ -93,10 +97,10 @@ def exception_handler(exc):
|
||||||
headers=headers)
|
headers=headers)
|
||||||
|
|
||||||
elif isinstance(exc, Http404):
|
elif isinstance(exc, Http404):
|
||||||
return NotFound({'detail': 'Not found'})
|
return NotFound({'detail': _('Not found')})
|
||||||
|
|
||||||
elif isinstance(exc, PermissionDenied):
|
elif isinstance(exc, PermissionDenied):
|
||||||
return Forbidden({'detail': 'Permission denied'})
|
return Forbidden({'detail': _('Permission denied')})
|
||||||
|
|
||||||
# Note: Unhandled exceptions will raise a 500 error.
|
# Note: Unhandled exceptions will raise a 500 error.
|
||||||
return None
|
return None
|
||||||
|
@ -292,7 +296,7 @@ class APIView(View):
|
||||||
"""
|
"""
|
||||||
request.user
|
request.user
|
||||||
|
|
||||||
def check_permissions(self, request, action, obj=None):
|
def check_permissions(self, request, action:str=None, obj=None):
|
||||||
if action is None:
|
if action is None:
|
||||||
self.permission_denied(request)
|
self.permission_denied(request)
|
||||||
|
|
||||||
|
@ -345,11 +349,9 @@ class APIView(View):
|
||||||
Returns the final response object.
|
Returns the final response object.
|
||||||
"""
|
"""
|
||||||
# Make the error obvious if a proper response is not returned
|
# Make the error obvious if a proper response is not returned
|
||||||
assert isinstance(response, HttpResponseBase), (
|
assert isinstance(response, HttpResponseBase), _('Expected a `Response`, `HttpResponse` or '
|
||||||
'Expected a `Response`, `HttpResponse` or `HttpStreamingResponse` '
|
'`HttpStreamingResponse` to be returned from the view, '
|
||||||
'to be returned from the view, but received a `%s`'
|
'but received a `%s`' % type(response))
|
||||||
% type(response)
|
|
||||||
)
|
|
||||||
|
|
||||||
if isinstance(response, Response):
|
if isinstance(response, Response):
|
||||||
if not getattr(request, 'accepted_renderer', None):
|
if not getattr(request, 'accepted_renderer', None):
|
||||||
|
@ -446,6 +448,6 @@ class APIView(View):
|
||||||
|
|
||||||
def api_server_error(request, *args, **kwargs):
|
def api_server_error(request, *args, **kwargs):
|
||||||
if settings.DEBUG is False and request.META['CONTENT_TYPE'] == "application/json":
|
if settings.DEBUG is False and request.META['CONTENT_TYPE'] == "application/json":
|
||||||
return HttpResponse(json.dumps({"error": "Server application error"}),
|
return HttpResponse(json.dumps({"error": _("Server application error")}),
|
||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
return server_error(request, *args, **kwargs)
|
return server_error(request, *args, **kwargs)
|
||||||
|
|
|
@ -19,11 +19,11 @@
|
||||||
|
|
||||||
from functools import update_wrapper
|
from functools import update_wrapper
|
||||||
from django.utils.decorators import classonlymethod
|
from django.utils.decorators import classonlymethod
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from . import views
|
from . import views
|
||||||
from . import mixins
|
from . import mixins
|
||||||
from . import generics
|
from . import generics
|
||||||
from . import pagination
|
|
||||||
|
|
||||||
|
|
||||||
class ViewSetMixin(object):
|
class ViewSetMixin(object):
|
||||||
|
@ -53,12 +53,12 @@ class ViewSetMixin(object):
|
||||||
# sanitize keyword arguments
|
# sanitize keyword arguments
|
||||||
for key in initkwargs:
|
for key in initkwargs:
|
||||||
if key in cls.http_method_names:
|
if key in cls.http_method_names:
|
||||||
raise TypeError("You tried to pass in the %s method name as a "
|
raise TypeError(_("You tried to pass in the %s method name as a "
|
||||||
"keyword argument to %s(). Don't do that."
|
"keyword argument to %s(). Don't do that."
|
||||||
% (key, cls.__name__))
|
% (key, cls.__name__)))
|
||||||
if not hasattr(cls, key):
|
if not hasattr(cls, key):
|
||||||
raise TypeError("%s() received an invalid keyword %r" % (
|
raise TypeError(_("%s() received an invalid keyword %r"
|
||||||
cls.__name__, key))
|
% (cls.__name__, key)))
|
||||||
|
|
||||||
def view(request, *args, **kwargs):
|
def view(request, *args, **kwargs):
|
||||||
self = cls(**initkwargs)
|
self = cls(**initkwargs)
|
||||||
|
@ -125,9 +125,7 @@ class GenericViewSet(ViewSetMixin, generics.GenericAPIView):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ReadOnlyListViewSet(pagination.HeadersPaginationMixin,
|
class ReadOnlyListViewSet(GenericViewSet):
|
||||||
pagination.ConditionalPaginationMixin,
|
|
||||||
GenericViewSet):
|
|
||||||
"""
|
"""
|
||||||
A viewset that provides default `list()` action.
|
A viewset that provides default `list()` action.
|
||||||
"""
|
"""
|
||||||
|
@ -156,15 +154,11 @@ class ModelViewSet(mixins.CreateModelMixin,
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ModelCrudViewSet(pagination.HeadersPaginationMixin,
|
class ModelCrudViewSet(ModelViewSet):
|
||||||
pagination.ConditionalPaginationMixin,
|
|
||||||
ModelViewSet):
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ModelListViewSet(pagination.HeadersPaginationMixin,
|
class ModelListViewSet(mixins.RetrieveModelMixin,
|
||||||
pagination.ConditionalPaginationMixin,
|
|
||||||
mixins.RetrieveModelMixin,
|
|
||||||
mixins.ListModelMixin,
|
mixins.ListModelMixin,
|
||||||
GenericViewSet):
|
GenericViewSet):
|
||||||
pass
|
pass
|
||||||
|
|
|
@ -13,19 +13,10 @@
|
||||||
#
|
#
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
import sys
|
|
||||||
|
|
||||||
# Patch api view for correctly return 401 responses on
|
|
||||||
# request is authenticated instead of 403
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
from . import monkey
|
|
||||||
|
|
||||||
|
|
||||||
class BaseAppConfig(AppConfig):
|
class BaseAppConfig(AppConfig):
|
||||||
name = "taiga.base"
|
name = "taiga.base"
|
||||||
verbose_name = "Base App Config"
|
verbose_name = "Base App Config"
|
||||||
|
|
||||||
def ready(self):
|
|
||||||
print("Monkey patching...", file=sys.stderr)
|
|
||||||
monkey.patch_restframework()
|
|
||||||
monkey.patch_serializer()
|
|
||||||
|
|
|
@ -14,18 +14,101 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from rest_framework import exceptions
|
# This code is partially taken from django-rest-framework:
|
||||||
from rest_framework import status
|
# Copyright (c) 2011-2015, Tom Christie
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
Handled exceptions raised by REST framework.
|
||||||
|
|
||||||
|
In addition Django's built in 403 and 404 exceptions are handled.
|
||||||
|
(`django.http.Http404` and `django.core.exceptions.PermissionDenied`)
|
||||||
|
"""
|
||||||
|
|
||||||
from django.core.exceptions import PermissionDenied as DjangoPermissionDenied
|
from django.core.exceptions import PermissionDenied as DjangoPermissionDenied
|
||||||
from django.utils.encoding import force_text
|
from django.utils.encoding import force_text
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
from django.http import Http404
|
from django.http import Http404
|
||||||
|
|
||||||
from taiga.base import response
|
from . import response
|
||||||
|
from . import status
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
class BaseException(exceptions.APIException):
|
class APIException(Exception):
|
||||||
|
"""
|
||||||
|
Base class for REST framework exceptions.
|
||||||
|
Subclasses should provide `.status_code` and `.default_detail` properties.
|
||||||
|
"""
|
||||||
|
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
default_detail = ""
|
||||||
|
|
||||||
|
def __init__(self, detail=None):
|
||||||
|
self.detail = detail or self.default_detail
|
||||||
|
|
||||||
|
|
||||||
|
class ParseError(APIException):
|
||||||
|
status_code = status.HTTP_400_BAD_REQUEST
|
||||||
|
default_detail = _("Malformed request.")
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationFailed(APIException):
|
||||||
|
status_code = status.HTTP_401_UNAUTHORIZED
|
||||||
|
default_detail = _("Incorrect authentication credentials.")
|
||||||
|
|
||||||
|
|
||||||
|
class NotAuthenticated(APIException):
|
||||||
|
status_code = status.HTTP_401_UNAUTHORIZED
|
||||||
|
default_detail = _("Authentication credentials were not provided.")
|
||||||
|
|
||||||
|
|
||||||
|
class PermissionDenied(APIException):
|
||||||
|
status_code = status.HTTP_403_FORBIDDEN
|
||||||
|
default_detail = _("You do not have permission to perform this action.")
|
||||||
|
|
||||||
|
|
||||||
|
class MethodNotAllowed(APIException):
|
||||||
|
status_code = status.HTTP_405_METHOD_NOT_ALLOWED
|
||||||
|
default_detail = _("Method '%s' not allowed.")
|
||||||
|
|
||||||
|
def __init__(self, method, detail=None):
|
||||||
|
self.detail = (detail or self.default_detail) % method
|
||||||
|
|
||||||
|
|
||||||
|
class NotAcceptable(APIException):
|
||||||
|
status_code = status.HTTP_406_NOT_ACCEPTABLE
|
||||||
|
default_detail = _("Could not satisfy the request's Accept header")
|
||||||
|
|
||||||
|
def __init__(self, detail=None, available_renderers=None):
|
||||||
|
self.detail = detail or self.default_detail
|
||||||
|
self.available_renderers = available_renderers
|
||||||
|
|
||||||
|
|
||||||
|
class UnsupportedMediaType(APIException):
|
||||||
|
status_code = status.HTTP_415_UNSUPPORTED_MEDIA_TYPE
|
||||||
|
default_detail = _("Unsupported media type '%s' in request.")
|
||||||
|
|
||||||
|
def __init__(self, media_type, detail=None):
|
||||||
|
self.detail = (detail or self.default_detail) % media_type
|
||||||
|
|
||||||
|
|
||||||
|
class Throttled(APIException):
|
||||||
|
status_code = status.HTTP_429_TOO_MANY_REQUESTS
|
||||||
|
default_detail = _("Request was throttled.")
|
||||||
|
extra_detail = _("Expected available in %d second%s.")
|
||||||
|
|
||||||
|
def __init__(self, wait=None, detail=None):
|
||||||
|
if wait is None:
|
||||||
|
self.detail = detail or self.default_detail
|
||||||
|
self.wait = None
|
||||||
|
else:
|
||||||
|
format = "%s%s" % ((detail or self.default_detail), self.extra_detail)
|
||||||
|
self.detail = format % (wait, wait != 1 and "s" or "")
|
||||||
|
self.wait = math.ceil(wait)
|
||||||
|
|
||||||
|
|
||||||
|
class BaseException(APIException):
|
||||||
status_code = status.HTTP_400_BAD_REQUEST
|
status_code = status.HTTP_400_BAD_REQUEST
|
||||||
default_detail = _("Unexpected error")
|
default_detail = _("Unexpected error")
|
||||||
|
|
||||||
|
@ -67,7 +150,7 @@ class RequestValidationError(BadRequest):
|
||||||
default_detail = _("Data validation error")
|
default_detail = _("Data validation error")
|
||||||
|
|
||||||
|
|
||||||
class PermissionDenied(exceptions.PermissionDenied):
|
class PermissionDenied(PermissionDenied):
|
||||||
"""
|
"""
|
||||||
Compatibility subclass of restframework `PermissionDenied`
|
Compatibility subclass of restframework `PermissionDenied`
|
||||||
exception.
|
exception.
|
||||||
|
@ -86,7 +169,7 @@ class PreconditionError(BadRequest):
|
||||||
default_detail = _("Precondition error")
|
default_detail = _("Precondition error")
|
||||||
|
|
||||||
|
|
||||||
class NotAuthenticated(exceptions.NotAuthenticated):
|
class NotAuthenticated(NotAuthenticated):
|
||||||
"""
|
"""
|
||||||
Compatibility subclass of restframework `NotAuthenticated`
|
Compatibility subclass of restframework `NotAuthenticated`
|
||||||
exception.
|
exception.
|
||||||
|
@ -119,7 +202,7 @@ def exception_handler(exc):
|
||||||
to be raised.
|
to be raised.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if isinstance(exc, exceptions.APIException):
|
if isinstance(exc, APIException):
|
||||||
headers = {}
|
headers = {}
|
||||||
if getattr(exc, "auth_header", None):
|
if getattr(exc, "auth_header", None):
|
||||||
headers["WWW-Authenticate"] = exc.auth_header
|
headers["WWW-Authenticate"] = exc.auth_header
|
||||||
|
|
|
@ -0,0 +1,109 @@
|
||||||
|
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from django.forms import widgets
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
|
from taiga.base.api import serializers
|
||||||
|
|
||||||
|
|
||||||
|
####################################################################
|
||||||
|
## Serializer fields
|
||||||
|
####################################################################
|
||||||
|
|
||||||
|
class JsonField(serializers.WritableField):
|
||||||
|
"""
|
||||||
|
Json objects serializer.
|
||||||
|
"""
|
||||||
|
widget = widgets.Textarea
|
||||||
|
|
||||||
|
def to_native(self, obj):
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def from_native(self, data):
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class I18NJsonField(JsonField):
|
||||||
|
"""
|
||||||
|
Json objects serializer.
|
||||||
|
"""
|
||||||
|
widget = widgets.Textarea
|
||||||
|
|
||||||
|
def __init__(self, i18n_fields=(), *args, **kwargs):
|
||||||
|
super(I18NJsonField, self).__init__(*args, **kwargs)
|
||||||
|
self.i18n_fields = i18n_fields
|
||||||
|
|
||||||
|
def translate_values(self, d):
|
||||||
|
i18n_d = {}
|
||||||
|
for key, value in d.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
i18n_d[key] = self.translate_values(value)
|
||||||
|
|
||||||
|
if key in self.i18n_fields:
|
||||||
|
if isinstance(value, list):
|
||||||
|
i18n_d[key] = [_(e) for e in value]
|
||||||
|
if isinstance(value, str):
|
||||||
|
i18n_d[key] = _(value)
|
||||||
|
else:
|
||||||
|
i18n_d[key] = value
|
||||||
|
|
||||||
|
return i18n_d
|
||||||
|
|
||||||
|
def to_native(self, obj):
|
||||||
|
i18n_obj = self.translate_values(obj)
|
||||||
|
return i18n_obj
|
||||||
|
|
||||||
|
|
||||||
|
class PgArrayField(serializers.WritableField):
|
||||||
|
"""
|
||||||
|
PgArray objects serializer.
|
||||||
|
"""
|
||||||
|
widget = widgets.Textarea
|
||||||
|
|
||||||
|
def to_native(self, obj):
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def from_native(self, data):
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class TagsField(serializers.WritableField):
|
||||||
|
"""
|
||||||
|
Pickle objects serializer.
|
||||||
|
"""
|
||||||
|
def to_native(self, obj):
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def from_native(self, data):
|
||||||
|
if not data:
|
||||||
|
return data
|
||||||
|
|
||||||
|
ret = sum([tag.split(",") for tag in data], [])
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
class TagsColorsField(serializers.WritableField):
|
||||||
|
"""
|
||||||
|
PgArray objects serializer.
|
||||||
|
"""
|
||||||
|
widget = widgets.Textarea
|
||||||
|
|
||||||
|
def to_native(self, obj):
|
||||||
|
return dict(obj)
|
||||||
|
|
||||||
|
def from_native(self, data):
|
||||||
|
return list(data.items())
|
|
@ -19,9 +19,7 @@ import logging
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from rest_framework import filters
|
|
||||||
|
|
||||||
from taiga.base import exceptions as exc
|
from taiga.base import exceptions as exc
|
||||||
from taiga.base.api.utils import get_object_or_404
|
from taiga.base.api.utils import get_object_or_404
|
||||||
|
@ -30,7 +28,20 @@ from taiga.base.api.utils import get_object_or_404
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class QueryParamsFilterMixin(filters.BaseFilterBackend):
|
|
||||||
|
class BaseFilterBackend(object):
|
||||||
|
"""
|
||||||
|
A base class from which all filter backend classes should inherit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def filter_queryset(self, request, queryset, view):
|
||||||
|
"""
|
||||||
|
Return a filtered queryset.
|
||||||
|
"""
|
||||||
|
raise NotImplementedError(".filter_queryset() must be overridden.")
|
||||||
|
|
||||||
|
|
||||||
|
class QueryParamsFilterMixin(BaseFilterBackend):
|
||||||
_special_values_dict = {
|
_special_values_dict = {
|
||||||
'true': True,
|
'true': True,
|
||||||
'false': False,
|
'false': False,
|
||||||
|
@ -60,7 +71,7 @@ class QueryParamsFilterMixin(filters.BaseFilterBackend):
|
||||||
try:
|
try:
|
||||||
queryset = queryset.filter(**query_params)
|
queryset = queryset.filter(**query_params)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise exc.BadRequest("Error in filter params types.")
|
raise exc.BadRequest(_("Error in filter params types."))
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
@ -104,10 +115,10 @@ class PermissionBasedFilterBackend(FilterBackend):
|
||||||
try:
|
try:
|
||||||
project_id = int(request.QUERY_PARAMS["project"])
|
project_id = int(request.QUERY_PARAMS["project"])
|
||||||
except:
|
except:
|
||||||
logger.error("Filtering project diferent value than an integer: {}".format(
|
logger.error(_("Filtering project diferent value than an integer: {}".format(
|
||||||
request.QUERY_PARAMS["project"]
|
request.QUERY_PARAMS["project"]
|
||||||
))
|
)))
|
||||||
raise exc.BadRequest("'project' must be an integer value.")
|
raise exc.BadRequest(_("'project' must be an integer value."))
|
||||||
|
|
||||||
qs = queryset
|
qs = queryset
|
||||||
|
|
||||||
|
@ -193,10 +204,10 @@ class CanViewProjectObjFilterBackend(FilterBackend):
|
||||||
try:
|
try:
|
||||||
project_id = int(request.QUERY_PARAMS["project"])
|
project_id = int(request.QUERY_PARAMS["project"])
|
||||||
except:
|
except:
|
||||||
logger.error("Filtering project diferent value than an integer: {}".format(
|
logger.error(_("Filtering project diferent value than an integer: {}".format(
|
||||||
request.QUERY_PARAMS["project"]
|
request.QUERY_PARAMS["project"]
|
||||||
))
|
)))
|
||||||
raise exc.BadRequest("'project' must be an integer value.")
|
raise exc.BadRequest(_("'project' must be an integer value."))
|
||||||
|
|
||||||
qs = queryset
|
qs = queryset
|
||||||
|
|
||||||
|
@ -250,8 +261,9 @@ class MembersFilterBackend(PermissionBasedFilterBackend):
|
||||||
try:
|
try:
|
||||||
project_id = int(request.QUERY_PARAMS["project"])
|
project_id = int(request.QUERY_PARAMS["project"])
|
||||||
except:
|
except:
|
||||||
logger.error("Filtering project diferent value than an integer: {}".format(request.QUERY_PARAMS["project"]))
|
logger.error(_("Filtering project diferent value than an integer: {}".format(
|
||||||
raise exc.BadRequest("'project' must be an integer value.")
|
request.QUERY_PARAMS["project"])))
|
||||||
|
raise exc.BadRequest(_("'project' must be an integer value."))
|
||||||
|
|
||||||
if project_id:
|
if project_id:
|
||||||
Project = apps.get_model('projects', 'Project')
|
Project = apps.get_model('projects', 'Project')
|
||||||
|
|
|
@ -16,6 +16,8 @@
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from optparse import make_option
|
||||||
|
|
||||||
from django.db.models.loading import get_model
|
from django.db.models.loading import get_model
|
||||||
from django.core.management.base import BaseCommand
|
from django.core.management.base import BaseCommand
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
@ -30,6 +32,11 @@ from taiga.users.models import User
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
args = '<email>'
|
args = '<email>'
|
||||||
|
option_list = BaseCommand.option_list + (
|
||||||
|
make_option('--locale', '-l', default=None, dest='locale',
|
||||||
|
help='Send emails in an specific language.'),
|
||||||
|
)
|
||||||
|
|
||||||
help = 'Send an example of all emails'
|
help = 'Send an example of all emails'
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
|
@ -37,12 +44,13 @@ class Command(BaseCommand):
|
||||||
print("Usage: ./manage.py test_emails <email-address>")
|
print("Usage: ./manage.py test_emails <email-address>")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
locale = options.get('locale')
|
||||||
test_email = args[0]
|
test_email = args[0]
|
||||||
|
|
||||||
mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail)
|
mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail)
|
||||||
|
|
||||||
# Register email
|
# Register email
|
||||||
context = {"user": User.objects.all().order_by("?").first(), "cancel_token": "cancel-token"}
|
context = {"lang": locale, "user": User.objects.all().order_by("?").first(), "cancel_token": "cancel-token"}
|
||||||
email = mbuilder.registered_user(test_email, context)
|
email = mbuilder.registered_user(test_email, context)
|
||||||
email.send()
|
email.send()
|
||||||
|
|
||||||
|
@ -51,17 +59,18 @@ class Command(BaseCommand):
|
||||||
membership.invited_by = User.objects.all().order_by("?").first()
|
membership.invited_by = User.objects.all().order_by("?").first()
|
||||||
membership.invitation_extra_text = "Text example, Text example,\nText example,\n\nText example"
|
membership.invitation_extra_text = "Text example, Text example,\nText example,\n\nText example"
|
||||||
|
|
||||||
context = {"membership": membership}
|
context = {"lang": locale, "membership": membership}
|
||||||
email = mbuilder.membership_invitation(test_email, context)
|
email = mbuilder.membership_invitation(test_email, context)
|
||||||
email.send()
|
email.send()
|
||||||
|
|
||||||
# Membership notification
|
# Membership notification
|
||||||
context = {"membership": Membership.objects.order_by("?").filter(user__isnull=False).first()}
|
context = {"lang": locale, "membership": Membership.objects.order_by("?").filter(user__isnull=False).first()}
|
||||||
email = mbuilder.membership_notification(test_email, context)
|
email = mbuilder.membership_notification(test_email, context)
|
||||||
email.send()
|
email.send()
|
||||||
|
|
||||||
# Feedback
|
# Feedback
|
||||||
context = {
|
context = {
|
||||||
|
"lang": locale,
|
||||||
"feedback_entry": {
|
"feedback_entry": {
|
||||||
"full_name": "Test full name",
|
"full_name": "Test full name",
|
||||||
"email": "test@email.com",
|
"email": "test@email.com",
|
||||||
|
@ -76,17 +85,18 @@ class Command(BaseCommand):
|
||||||
email.send()
|
email.send()
|
||||||
|
|
||||||
# Password recovery
|
# Password recovery
|
||||||
context = {"user": User.objects.all().order_by("?").first()}
|
context = {"lang": locale, "user": User.objects.all().order_by("?").first()}
|
||||||
email = mbuilder.password_recovery(test_email, context)
|
email = mbuilder.password_recovery(test_email, context)
|
||||||
email.send()
|
email.send()
|
||||||
|
|
||||||
# Change email
|
# Change email
|
||||||
context = {"user": User.objects.all().order_by("?").first()}
|
context = {"lang": locale, "user": User.objects.all().order_by("?").first()}
|
||||||
email = mbuilder.change_email(test_email, context)
|
email = mbuilder.change_email(test_email, context)
|
||||||
email.send()
|
email.send()
|
||||||
|
|
||||||
# Export/Import emails
|
# Export/Import emails
|
||||||
context = {
|
context = {
|
||||||
|
"lang": locale,
|
||||||
"user": User.objects.all().order_by("?").first(),
|
"user": User.objects.all().order_by("?").first(),
|
||||||
"project": Project.objects.all().order_by("?").first(),
|
"project": Project.objects.all().order_by("?").first(),
|
||||||
"error_subject": "Error generating project dump",
|
"error_subject": "Error generating project dump",
|
||||||
|
@ -95,6 +105,7 @@ class Command(BaseCommand):
|
||||||
email = mbuilder.export_error(test_email, context)
|
email = mbuilder.export_error(test_email, context)
|
||||||
email.send()
|
email.send()
|
||||||
context = {
|
context = {
|
||||||
|
"lang": locale,
|
||||||
"user": User.objects.all().order_by("?").first(),
|
"user": User.objects.all().order_by("?").first(),
|
||||||
"error_subject": "Error importing project dump",
|
"error_subject": "Error importing project dump",
|
||||||
"error_message": "Error importing project dump",
|
"error_message": "Error importing project dump",
|
||||||
|
@ -104,6 +115,7 @@ class Command(BaseCommand):
|
||||||
|
|
||||||
deletion_date = timezone.now() + datetime.timedelta(seconds=60*60*24)
|
deletion_date = timezone.now() + datetime.timedelta(seconds=60*60*24)
|
||||||
context = {
|
context = {
|
||||||
|
"lang": locale,
|
||||||
"url": "http://dummyurl.com",
|
"url": "http://dummyurl.com",
|
||||||
"user": User.objects.all().order_by("?").first(),
|
"user": User.objects.all().order_by("?").first(),
|
||||||
"project": Project.objects.all().order_by("?").first(),
|
"project": Project.objects.all().order_by("?").first(),
|
||||||
|
@ -113,6 +125,7 @@ class Command(BaseCommand):
|
||||||
email.send()
|
email.send()
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
|
"lang": locale,
|
||||||
"user": User.objects.all().order_by("?").first(),
|
"user": User.objects.all().order_by("?").first(),
|
||||||
"project": Project.objects.all().order_by("?").first(),
|
"project": Project.objects.all().order_by("?").first(),
|
||||||
}
|
}
|
||||||
|
@ -139,6 +152,7 @@ class Command(BaseCommand):
|
||||||
]
|
]
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
|
"lang": locale,
|
||||||
"project": Project.objects.all().order_by("?").first(),
|
"project": Project.objects.all().order_by("?").first(),
|
||||||
"changer": User.objects.all().order_by("?").first(),
|
"changer": User.objects.all().order_by("?").first(),
|
||||||
"history_entries": HistoryEntry.objects.all().order_by("?")[0:5],
|
"history_entries": HistoryEntry.objects.all().order_by("?")[0:5],
|
||||||
|
|
|
@ -1,50 +0,0 @@
|
||||||
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
|
|
||||||
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
|
|
||||||
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as
|
|
||||||
# published by the Free Software Foundation, either version 3 of the
|
|
||||||
# License, or (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
from __future__ import print_function
|
|
||||||
|
|
||||||
|
|
||||||
def patch_serializer():
|
|
||||||
from rest_framework import serializers
|
|
||||||
if hasattr(serializers.BaseSerializer, "_patched"):
|
|
||||||
return
|
|
||||||
|
|
||||||
def to_native(self, obj):
|
|
||||||
"""
|
|
||||||
Serialize objects -> primitives.
|
|
||||||
"""
|
|
||||||
ret = self._dict_class()
|
|
||||||
ret.fields = self._dict_class()
|
|
||||||
ret.empty = obj is None
|
|
||||||
|
|
||||||
for field_name, field in self.fields.items():
|
|
||||||
field.initialize(parent=self, field_name=field_name)
|
|
||||||
key = self.get_field_key(field_name)
|
|
||||||
ret.fields[key] = field
|
|
||||||
|
|
||||||
if obj is not None:
|
|
||||||
value = field.field_to_native(obj, field_name)
|
|
||||||
ret[key] = value
|
|
||||||
|
|
||||||
return ret
|
|
||||||
|
|
||||||
serializers.BaseSerializer._patched = True
|
|
||||||
serializers.BaseSerializer.to_native = to_native
|
|
||||||
|
|
||||||
|
|
||||||
def patch_restframework():
|
|
||||||
from rest_framework import fields
|
|
||||||
fields.strip_multiple_choice_msg = lambda x: x
|
|
|
@ -19,6 +19,8 @@ from collections import namedtuple
|
||||||
|
|
||||||
from django.db import connection
|
from django.db import connection
|
||||||
|
|
||||||
|
from taiga.base.api import serializers
|
||||||
|
|
||||||
Neighbor = namedtuple("Neighbor", "left right")
|
Neighbor = namedtuple("Neighbor", "left right")
|
||||||
|
|
||||||
|
|
||||||
|
@ -67,3 +69,25 @@ def get_neighbors(obj, results_set=None):
|
||||||
right = None
|
right = None
|
||||||
|
|
||||||
return Neighbor(left, right)
|
return Neighbor(left, right)
|
||||||
|
|
||||||
|
|
||||||
|
class NeighborsSerializerMixin:
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self.fields["neighbors"] = serializers.SerializerMethodField("get_neighbors")
|
||||||
|
|
||||||
|
def serialize_neighbor(self, neighbor):
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def get_neighbors(self, obj):
|
||||||
|
view, request = self.context.get("view", None), self.context.get("request", None)
|
||||||
|
if view and request:
|
||||||
|
queryset = view.filter_queryset(view.get_queryset())
|
||||||
|
left, right = get_neighbors(obj, results_set=queryset)
|
||||||
|
else:
|
||||||
|
left = right = None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"previous": self.serialize_neighbor(left),
|
||||||
|
"next": self.serialize_neighbor(right)
|
||||||
|
}
|
||||||
|
|
|
@ -15,17 +15,92 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2014, Tom Christie
|
||||||
|
|
||||||
"""The various HTTP responses for use in returning proper HTTP codes."""
|
"""The various HTTP responses for use in returning proper HTTP codes."""
|
||||||
from django import http
|
from django import http
|
||||||
|
|
||||||
import rest_framework.response
|
from django.core.handlers.wsgi import STATUS_CODE_TEXT
|
||||||
|
from django.template.response import SimpleTemplateResponse
|
||||||
|
from django.utils import six
|
||||||
|
|
||||||
|
|
||||||
class Response(rest_framework.response.Response):
|
class Response(SimpleTemplateResponse):
|
||||||
def __init__(self, data=None, status=None, template_name=None, headers=None, exception=False,
|
"""
|
||||||
content_type=None):
|
An HttpResponse that allows its data to be rendered into
|
||||||
super(Response, self).__init__(data, status, template_name, headers, exception,
|
arbitrary media types.
|
||||||
content_type)
|
"""
|
||||||
|
def __init__(self, data=None, status=None,
|
||||||
|
template_name=None, headers=None,
|
||||||
|
exception=False, content_type=None):
|
||||||
|
"""
|
||||||
|
Alters the init arguments slightly.
|
||||||
|
For example, drop 'template_name', and instead use 'data'.
|
||||||
|
|
||||||
|
Setting 'renderer' and 'media_type' will typically be deferred,
|
||||||
|
For example being set automatically by the `APIView`.
|
||||||
|
"""
|
||||||
|
super().__init__(None, status=status)
|
||||||
|
self.data = data
|
||||||
|
self.template_name = template_name
|
||||||
|
self.exception = exception
|
||||||
|
self.content_type = content_type
|
||||||
|
|
||||||
|
if headers:
|
||||||
|
for name, value in six.iteritems(headers):
|
||||||
|
self[name] = value
|
||||||
|
|
||||||
|
@property
|
||||||
|
def rendered_content(self):
|
||||||
|
renderer = getattr(self, "accepted_renderer", None)
|
||||||
|
media_type = getattr(self, "accepted_media_type", None)
|
||||||
|
context = getattr(self, "renderer_context", None)
|
||||||
|
|
||||||
|
assert renderer, ".accepted_renderer not set on Response"
|
||||||
|
assert media_type, ".accepted_media_type not set on Response"
|
||||||
|
assert context, ".renderer_context not set on Response"
|
||||||
|
context["response"] = self
|
||||||
|
|
||||||
|
charset = renderer.charset
|
||||||
|
content_type = self.content_type
|
||||||
|
|
||||||
|
if content_type is None and charset is not None:
|
||||||
|
content_type = "{0}; charset={1}".format(media_type, charset)
|
||||||
|
elif content_type is None:
|
||||||
|
content_type = media_type
|
||||||
|
self["Content-Type"] = content_type
|
||||||
|
|
||||||
|
ret = renderer.render(self.data, media_type, context)
|
||||||
|
if isinstance(ret, six.text_type):
|
||||||
|
assert charset, "renderer returned unicode, and did not specify " \
|
||||||
|
"a charset value."
|
||||||
|
return bytes(ret.encode(charset))
|
||||||
|
|
||||||
|
if not ret:
|
||||||
|
del self["Content-Type"]
|
||||||
|
|
||||||
|
return ret
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_text(self):
|
||||||
|
"""
|
||||||
|
Returns reason text corresponding to our HTTP response status code.
|
||||||
|
Provided for convenience.
|
||||||
|
"""
|
||||||
|
# TODO: Deprecate and use a template tag instead
|
||||||
|
# TODO: Status code text for RFC 6585 status codes
|
||||||
|
return STATUS_CODE_TEXT.get(self.status_code, '')
|
||||||
|
|
||||||
|
def __getstate__(self):
|
||||||
|
"""
|
||||||
|
Remove attributes from the response that shouldn't be cached
|
||||||
|
"""
|
||||||
|
state = super().__getstate__()
|
||||||
|
for key in ("accepted_renderer", "renderer_context", "data"):
|
||||||
|
if key in state:
|
||||||
|
del state[key]
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
class Ok(Response):
|
class Ok(Response):
|
||||||
|
|
|
@ -22,10 +22,10 @@ from django.conf.urls import patterns, url
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.core.urlresolvers import NoReverseMatch
|
from django.core.urlresolvers import NoReverseMatch
|
||||||
|
|
||||||
from rest_framework import views
|
from taiga.base.api import views
|
||||||
from taiga.base import response
|
from taiga.base import response
|
||||||
from rest_framework.reverse import reverse
|
from taiga.base.api.reverse import reverse
|
||||||
from rest_framework.urlpatterns import format_suffix_patterns
|
from taiga.base.api.urlpatterns import format_suffix_patterns
|
||||||
|
|
||||||
|
|
||||||
Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs'])
|
Route = namedtuple('Route', ['url', 'mapping', 'name', 'initkwargs'])
|
||||||
|
|
|
@ -1,159 +0,0 @@
|
||||||
# Copyright (C) 2014 Andrey Antukh <niwi@niwi.be>
|
|
||||||
# Copyright (C) 2014 Jesús Espino <jespinog@gmail.com>
|
|
||||||
# Copyright (C) 2014 David Barragán <bameda@dbarragan.com>
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU Affero General Public License as
|
|
||||||
# published by the Free Software Foundation, either version 3 of the
|
|
||||||
# License, or (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU Affero General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
|
|
||||||
from django.forms import widgets
|
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
|
||||||
from .neighbors import get_neighbors
|
|
||||||
|
|
||||||
|
|
||||||
class TagsField(serializers.WritableField):
|
|
||||||
"""
|
|
||||||
Pickle objects serializer.
|
|
||||||
"""
|
|
||||||
def to_native(self, obj):
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def from_native(self, data):
|
|
||||||
if not data:
|
|
||||||
return data
|
|
||||||
|
|
||||||
ret = sum([tag.split(",") for tag in data], [])
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
class JsonField(serializers.WritableField):
|
|
||||||
"""
|
|
||||||
Json objects serializer.
|
|
||||||
"""
|
|
||||||
widget = widgets.Textarea
|
|
||||||
|
|
||||||
def to_native(self, obj):
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def from_native(self, data):
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class PgArrayField(serializers.WritableField):
|
|
||||||
"""
|
|
||||||
PgArray objects serializer.
|
|
||||||
"""
|
|
||||||
widget = widgets.Textarea
|
|
||||||
|
|
||||||
def to_native(self, obj):
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def from_native(self, data):
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class TagsColorsField(serializers.WritableField):
|
|
||||||
"""
|
|
||||||
PgArray objects serializer.
|
|
||||||
"""
|
|
||||||
widget = widgets.Textarea
|
|
||||||
|
|
||||||
def to_native(self, obj):
|
|
||||||
return dict(obj)
|
|
||||||
|
|
||||||
def from_native(self, data):
|
|
||||||
return list(data.items())
|
|
||||||
|
|
||||||
|
|
||||||
class NeighborsSerializerMixin:
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
self.fields["neighbors"] = serializers.SerializerMethodField("get_neighbors")
|
|
||||||
|
|
||||||
def serialize_neighbor(self, neighbor):
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def get_neighbors(self, obj):
|
|
||||||
view, request = self.context.get("view", None), self.context.get("request", None)
|
|
||||||
if view and request:
|
|
||||||
queryset = view.filter_queryset(view.get_queryset())
|
|
||||||
left, right = get_neighbors(obj, results_set=queryset)
|
|
||||||
else:
|
|
||||||
left = right = None
|
|
||||||
|
|
||||||
return {
|
|
||||||
"previous": self.serialize_neighbor(left),
|
|
||||||
"next": self.serialize_neighbor(right)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class Serializer(serializers.Serializer):
|
|
||||||
def skip_field_validation(self, field, attrs, source):
|
|
||||||
return source not in attrs and (field.partial or not field.required)
|
|
||||||
|
|
||||||
def perform_validation(self, attrs):
|
|
||||||
"""
|
|
||||||
Run `validate_<fieldname>()` and `validate()` methods on the serializer
|
|
||||||
"""
|
|
||||||
for field_name, field in self.fields.items():
|
|
||||||
if field_name in self._errors:
|
|
||||||
continue
|
|
||||||
|
|
||||||
source = field.source or field_name
|
|
||||||
if self.skip_field_validation(field, attrs, source):
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
validate_method = getattr(self, 'validate_%s' % field_name, None)
|
|
||||||
if validate_method:
|
|
||||||
attrs = validate_method(attrs, source)
|
|
||||||
except serializers.ValidationError as err:
|
|
||||||
self._errors[field_name] = self._errors.get(field_name, []) + list(err.messages)
|
|
||||||
|
|
||||||
# If there are already errors, we don't run .validate() because
|
|
||||||
# field-validation failed and thus `attrs` may not be complete.
|
|
||||||
# which in turn can cause inconsistent validation errors.
|
|
||||||
if not self._errors:
|
|
||||||
try:
|
|
||||||
attrs = self.validate(attrs)
|
|
||||||
except serializers.ValidationError as err:
|
|
||||||
if hasattr(err, 'message_dict'):
|
|
||||||
for field_name, error_messages in err.message_dict.items():
|
|
||||||
self._errors[field_name] = self._errors.get(field_name, []) + list(error_messages)
|
|
||||||
elif hasattr(err, 'messages'):
|
|
||||||
self._errors['non_field_errors'] = err.messages
|
|
||||||
|
|
||||||
return attrs
|
|
||||||
|
|
||||||
|
|
||||||
class ModelSerializer(serializers.ModelSerializer):
|
|
||||||
def perform_validation(self, attrs):
|
|
||||||
for attr in attrs:
|
|
||||||
field = self.fields.get(attr, None)
|
|
||||||
if field:
|
|
||||||
field.required = True
|
|
||||||
return super().perform_validation(attrs)
|
|
||||||
|
|
||||||
def save(self, **kwargs):
|
|
||||||
"""
|
|
||||||
Due to DRF bug with M2M fields we refresh object state from database
|
|
||||||
directly if object is models.Model type and it contains m2m fields
|
|
||||||
|
|
||||||
See: https://github.com/tomchristie/django-rest-framework/issues/1556
|
|
||||||
"""
|
|
||||||
self.object = super(serializers.ModelSerializer, self).save(**kwargs)
|
|
||||||
model = self.Meta.model
|
|
||||||
if model._meta.model._meta.local_many_to_many and self.object.pk:
|
|
||||||
self.object = model.objects.get(pk=self.object.pk)
|
|
||||||
return self.object
|
|
|
@ -0,0 +1,89 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
# This code is partially taken from django-rest-framework:
|
||||||
|
# Copyright (c) 2011-2015, Tom Christie
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
Descriptive HTTP status codes, for code readability.
|
||||||
|
|
||||||
|
See RFC 2616 - http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html
|
||||||
|
And RFC 6585 - http://tools.ietf.org/html/rfc6585
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def is_informational(code):
|
||||||
|
return code >= 100 and code <= 199
|
||||||
|
|
||||||
|
def is_success(code):
|
||||||
|
return code >= 200 and code <= 299
|
||||||
|
|
||||||
|
def is_redirect(code):
|
||||||
|
return code >= 300 and code <= 399
|
||||||
|
|
||||||
|
def is_client_error(code):
|
||||||
|
return code >= 400 and code <= 499
|
||||||
|
|
||||||
|
def is_server_error(code):
|
||||||
|
return code >= 500 and code <= 599
|
||||||
|
|
||||||
|
|
||||||
|
HTTP_100_CONTINUE = 100
|
||||||
|
HTTP_101_SWITCHING_PROTOCOLS = 101
|
||||||
|
HTTP_200_OK = 200
|
||||||
|
HTTP_201_CREATED = 201
|
||||||
|
HTTP_202_ACCEPTED = 202
|
||||||
|
HTTP_203_NON_AUTHORITATIVE_INFORMATION = 203
|
||||||
|
HTTP_204_NO_CONTENT = 204
|
||||||
|
HTTP_205_RESET_CONTENT = 205
|
||||||
|
HTTP_206_PARTIAL_CONTENT = 206
|
||||||
|
HTTP_300_MULTIPLE_CHOICES = 300
|
||||||
|
HTTP_301_MOVED_PERMANENTLY = 301
|
||||||
|
HTTP_302_FOUND = 302
|
||||||
|
HTTP_303_SEE_OTHER = 303
|
||||||
|
HTTP_304_NOT_MODIFIED = 304
|
||||||
|
HTTP_305_USE_PROXY = 305
|
||||||
|
HTTP_306_RESERVED = 306
|
||||||
|
HTTP_307_TEMPORARY_REDIRECT = 307
|
||||||
|
HTTP_400_BAD_REQUEST = 400
|
||||||
|
HTTP_401_UNAUTHORIZED = 401
|
||||||
|
HTTP_402_PAYMENT_REQUIRED = 402
|
||||||
|
HTTP_403_FORBIDDEN = 403
|
||||||
|
HTTP_404_NOT_FOUND = 404
|
||||||
|
HTTP_405_METHOD_NOT_ALLOWED = 405
|
||||||
|
HTTP_406_NOT_ACCEPTABLE = 406
|
||||||
|
HTTP_407_PROXY_AUTHENTICATION_REQUIRED = 407
|
||||||
|
HTTP_408_REQUEST_TIMEOUT = 408
|
||||||
|
HTTP_409_CONFLICT = 409
|
||||||
|
HTTP_410_GONE = 410
|
||||||
|
HTTP_411_LENGTH_REQUIRED = 411
|
||||||
|
HTTP_412_PRECONDITION_FAILED = 412
|
||||||
|
HTTP_413_REQUEST_ENTITY_TOO_LARGE = 413
|
||||||
|
HTTP_414_REQUEST_URI_TOO_LONG = 414
|
||||||
|
HTTP_415_UNSUPPORTED_MEDIA_TYPE = 415
|
||||||
|
HTTP_416_REQUESTED_RANGE_NOT_SATISFIABLE = 416
|
||||||
|
HTTP_417_EXPECTATION_FAILED = 417
|
||||||
|
HTTP_428_PRECONDITION_REQUIRED = 428
|
||||||
|
HTTP_429_TOO_MANY_REQUESTS = 429
|
||||||
|
HTTP_431_REQUEST_HEADER_FIELDS_TOO_LARGE = 431
|
||||||
|
HTTP_500_INTERNAL_SERVER_ERROR = 500
|
||||||
|
HTTP_501_NOT_IMPLEMENTED = 501
|
||||||
|
HTTP_502_BAD_GATEWAY = 502
|
||||||
|
HTTP_503_SERVICE_UNAVAILABLE = 503
|
||||||
|
HTTP_504_GATEWAY_TIMEOUT = 504
|
||||||
|
HTTP_505_HTTP_VERSION_NOT_SUPPORTED = 505
|
||||||
|
HTTP_511_NETWORK_AUTHENTICATION_REQUIRED = 511
|
|
@ -14,7 +14,7 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from rest_framework import throttling
|
from taiga.base.api import throttling
|
||||||
|
|
||||||
|
|
||||||
class AnonRateThrottle(throttling.AnonRateThrottle):
|
class AnonRateThrottle(throttling.AnonRateThrottle):
|
||||||
|
|
|
@ -14,10 +14,12 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import json
|
|
||||||
from rest_framework.utils import encoders
|
|
||||||
from django.utils.encoding import force_text
|
from django.utils.encoding import force_text
|
||||||
|
|
||||||
|
from taiga.base.api.utils import encoders
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
def dumps(data, ensure_ascii=True, encoder_class=encoders.JSONEncoder):
|
def dumps(data, ensure_ascii=True, encoder_class=encoders.JSONEncoder):
|
||||||
return json.dumps(data, cls=encoder_class, indent=None, ensure_ascii=ensure_ascii)
|
return json.dumps(data, cls=encoder_class, indent=None, ensure_ascii=ensure_ascii)
|
||||||
|
|
|
@ -15,6 +15,8 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
|
|
||||||
|
|
||||||
|
@ -22,7 +24,7 @@ from contextlib import contextmanager
|
||||||
def without_signals(*disablers):
|
def without_signals(*disablers):
|
||||||
for disabler in disablers:
|
for disabler in disablers:
|
||||||
if not (isinstance(disabler, list) or isinstance(disabler, tuple)) or len(disabler) == 0:
|
if not (isinstance(disabler, list) or isinstance(disabler, tuple)) or len(disabler) == 0:
|
||||||
raise ValueError("The parameters must be lists of at least one parameter (the signal)")
|
raise ValueError(_("The parameters must be lists of at least one parameter (the signal)."))
|
||||||
|
|
||||||
signal, *ids = disabler
|
signal, *ids = disabler
|
||||||
signal.backup_receivers = signal.receivers
|
signal.backup_receivers = signal.receivers
|
||||||
|
|
|
@ -19,7 +19,7 @@ import codecs
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.db.transaction import atomic
|
from django.db.transaction import atomic
|
||||||
from django.db.models import signals
|
from django.db.models import signals
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
|
@ -14,6 +14,8 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from taiga.projects.models import Membership
|
from taiga.projects.models import Membership
|
||||||
|
|
||||||
from . import serializers
|
from . import serializers
|
||||||
|
@ -83,7 +85,7 @@ def dict_to_project(data, owner=None):
|
||||||
project_serialized = service.store_project(data)
|
project_serialized = service.store_project(data)
|
||||||
|
|
||||||
if not project_serialized:
|
if not project_serialized:
|
||||||
raise TaigaImportError('error importing project')
|
raise TaigaImportError(_('error importing project'))
|
||||||
|
|
||||||
proj = project_serialized.object
|
proj = project_serialized.object
|
||||||
|
|
||||||
|
@ -96,12 +98,12 @@ def dict_to_project(data, owner=None):
|
||||||
service.store_choices(proj, data, "severities", serializers.SeverityExportSerializer)
|
service.store_choices(proj, data, "severities", serializers.SeverityExportSerializer)
|
||||||
|
|
||||||
if service.get_errors(clear=False):
|
if service.get_errors(clear=False):
|
||||||
raise TaigaImportError('error importing choices')
|
raise TaigaImportError(_('error importing choices'))
|
||||||
|
|
||||||
service.store_default_choices(proj, data)
|
service.store_default_choices(proj, data)
|
||||||
|
|
||||||
if service.get_errors(clear=False):
|
if service.get_errors(clear=False):
|
||||||
raise TaigaImportError('error importing default choices')
|
raise TaigaImportError(_('error importing default choices'))
|
||||||
|
|
||||||
service.store_custom_attributes(proj, data, "userstorycustomattributes",
|
service.store_custom_attributes(proj, data, "userstorycustomattributes",
|
||||||
serializers.UserStoryCustomAttributeExportSerializer)
|
serializers.UserStoryCustomAttributeExportSerializer)
|
||||||
|
@ -111,12 +113,12 @@ def dict_to_project(data, owner=None):
|
||||||
serializers.IssueCustomAttributeExportSerializer)
|
serializers.IssueCustomAttributeExportSerializer)
|
||||||
|
|
||||||
if service.get_errors(clear=False):
|
if service.get_errors(clear=False):
|
||||||
raise TaigaImportError('error importing custom attributes')
|
raise TaigaImportError(_('error importing custom fields'))
|
||||||
|
|
||||||
service.store_roles(proj, data)
|
service.store_roles(proj, data)
|
||||||
|
|
||||||
if service.get_errors(clear=False):
|
if service.get_errors(clear=False):
|
||||||
raise TaigaImportError('error importing roles')
|
raise TaigaImportError(_('error importing roles'))
|
||||||
|
|
||||||
service.store_memberships(proj, data)
|
service.store_memberships(proj, data)
|
||||||
|
|
||||||
|
@ -131,37 +133,37 @@ def dict_to_project(data, owner=None):
|
||||||
)
|
)
|
||||||
|
|
||||||
if service.get_errors(clear=False):
|
if service.get_errors(clear=False):
|
||||||
raise TaigaImportError('error importing memberships')
|
raise TaigaImportError(_('error importing memberships'))
|
||||||
|
|
||||||
store_milestones(proj, data)
|
store_milestones(proj, data)
|
||||||
|
|
||||||
if service.get_errors(clear=False):
|
if service.get_errors(clear=False):
|
||||||
raise TaigaImportError('error importing milestones')
|
raise TaigaImportError(_('error importing milestones'))
|
||||||
|
|
||||||
store_wiki_pages(proj, data)
|
store_wiki_pages(proj, data)
|
||||||
|
|
||||||
if service.get_errors(clear=False):
|
if service.get_errors(clear=False):
|
||||||
raise TaigaImportError('error importing wiki pages')
|
raise TaigaImportError(_('error importing wiki pages'))
|
||||||
|
|
||||||
store_wiki_links(proj, data)
|
store_wiki_links(proj, data)
|
||||||
|
|
||||||
if service.get_errors(clear=False):
|
if service.get_errors(clear=False):
|
||||||
raise TaigaImportError('error importing wiki links')
|
raise TaigaImportError(_('error importing wiki links'))
|
||||||
|
|
||||||
store_issues(proj, data)
|
store_issues(proj, data)
|
||||||
|
|
||||||
if service.get_errors(clear=False):
|
if service.get_errors(clear=False):
|
||||||
raise TaigaImportError('error importing issues')
|
raise TaigaImportError(_('error importing issues'))
|
||||||
|
|
||||||
store_user_stories(proj, data)
|
store_user_stories(proj, data)
|
||||||
|
|
||||||
if service.get_errors(clear=False):
|
if service.get_errors(clear=False):
|
||||||
raise TaigaImportError('error importing user stories')
|
raise TaigaImportError(_('error importing user stories'))
|
||||||
|
|
||||||
store_tasks(proj, data)
|
store_tasks(proj, data)
|
||||||
|
|
||||||
if service.get_errors(clear=False):
|
if service.get_errors(clear=False):
|
||||||
raise TaigaImportError('error importing issues')
|
raise TaigaImportError(_('error importing issues'))
|
||||||
|
|
||||||
store_tags_colors(proj, data)
|
store_tags_colors(proj, data)
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from rest_framework.renderers import UnicodeJSONRenderer
|
from taiga.base.api.renderers import UnicodeJSONRenderer
|
||||||
|
|
||||||
|
|
||||||
class ExportRenderer(UnicodeJSONRenderer):
|
class ExportRenderer(UnicodeJSONRenderer):
|
||||||
|
|
|
@ -18,13 +18,17 @@ import base64
|
||||||
import os
|
import os
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
|
||||||
from rest_framework import serializers
|
|
||||||
|
from taiga import mdrender
|
||||||
|
from taiga.base.api import serializers
|
||||||
|
from taiga.base.fields import JsonField, PgArrayField
|
||||||
|
|
||||||
from taiga.projects import models as projects_models
|
from taiga.projects import models as projects_models
|
||||||
from taiga.projects.custom_attributes import models as custom_attributes_models
|
from taiga.projects.custom_attributes import models as custom_attributes_models
|
||||||
|
@ -38,8 +42,6 @@ from taiga.projects.attachments import models as attachments_models
|
||||||
from taiga.users import models as users_models
|
from taiga.users import models as users_models
|
||||||
from taiga.projects.votes import services as votes_service
|
from taiga.projects.votes import services as votes_service
|
||||||
from taiga.projects.history import services as history_service
|
from taiga.projects.history import services as history_service
|
||||||
from taiga.base.serializers import JsonField, PgArrayField
|
|
||||||
from taiga import mdrender
|
|
||||||
|
|
||||||
|
|
||||||
class AttachedFileField(serializers.WritableField):
|
class AttachedFileField(serializers.WritableField):
|
||||||
|
@ -153,7 +155,7 @@ class ProjectRelatedField(serializers.RelatedField):
|
||||||
kwargs = {self.slug_field: data, "project": self.context['project']}
|
kwargs = {self.slug_field: data, "project": self.context['project']}
|
||||||
return self.queryset.get(**kwargs)
|
return self.queryset.get(**kwargs)
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
raise ValidationError("{}=\"{}\" not found in this project".format(self.slug_field, data))
|
raise ValidationError(_("{}=\"{}\" not found in this project".format(self.slug_field, data)))
|
||||||
|
|
||||||
|
|
||||||
class HistoryUserField(JsonField):
|
class HistoryUserField(JsonField):
|
||||||
|
@ -458,7 +460,7 @@ class MilestoneExportSerializer(serializers.ModelSerializer):
|
||||||
name = attrs[source]
|
name = attrs[source]
|
||||||
qs = self.project.milestones.filter(name=name)
|
qs = self.project.milestones.filter(name=name)
|
||||||
if qs.exists():
|
if qs.exists():
|
||||||
raise serializers.ValidationError("Name duplicated for the project")
|
raise serializers.ValidationError(_("Name duplicated for the project"))
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
||||||
|
|
|
@ -20,6 +20,7 @@ from django.core.files.storage import default_storage
|
||||||
from django.core.files.base import ContentFile
|
from django.core.files.base import ContentFile
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail
|
from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail
|
||||||
|
|
||||||
|
@ -45,11 +46,11 @@ def dump_project(self, user, project):
|
||||||
except Exception:
|
except Exception:
|
||||||
ctx = {
|
ctx = {
|
||||||
"user": user,
|
"user": user,
|
||||||
"error_subject": "Error generating project dump",
|
"error_subject": _("Error generating project dump"),
|
||||||
"error_message": "Error generating project dump",
|
"error_message": _("Error generating project dump"),
|
||||||
"project": project
|
"project": project
|
||||||
}
|
}
|
||||||
email = mbuilder.export_error(user.email, ctx)
|
email = mbuilder.export_error(user, ctx)
|
||||||
email.send()
|
email.send()
|
||||||
return
|
return
|
||||||
|
|
||||||
|
@ -60,7 +61,7 @@ def dump_project(self, user, project):
|
||||||
"user": user,
|
"user": user,
|
||||||
"deletion_date": deletion_date
|
"deletion_date": deletion_date
|
||||||
}
|
}
|
||||||
email = mbuilder.dump_project(user.email, ctx)
|
email = mbuilder.dump_project(user, ctx)
|
||||||
email.send()
|
email.send()
|
||||||
|
|
||||||
|
|
||||||
|
@ -78,13 +79,13 @@ def load_project_dump(user, dump):
|
||||||
except Exception:
|
except Exception:
|
||||||
ctx = {
|
ctx = {
|
||||||
"user": user,
|
"user": user,
|
||||||
"error_subject": "Error loading project dump",
|
"error_subject": _("Error loading project dump"),
|
||||||
"error_message": "Error loading project dump",
|
"error_message": _("Error loading project dump"),
|
||||||
}
|
}
|
||||||
email = mbuilder.import_error(user.email, ctx)
|
email = mbuilder.import_error(user, ctx)
|
||||||
email.send()
|
email.send()
|
||||||
return
|
return
|
||||||
|
|
||||||
ctx = {"user": user, "project": project}
|
ctx = {"user": user, "project": project}
|
||||||
email = mbuilder.load_dump(user.email, ctx)
|
email = mbuilder.load_dump(user, ctx)
|
||||||
email.send()
|
email.send()
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from rest_framework import serializers
|
from taiga.base.api import serializers
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from taiga.base import exceptions as exc
|
from taiga.base import exceptions as exc
|
||||||
from taiga.base import response
|
from taiga.base import response
|
||||||
|
|
|
@ -16,7 +16,7 @@
|
||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from taiga.base import exceptions as exc
|
from taiga.base import exceptions as exc
|
||||||
from taiga.projects.models import IssueStatus, TaskStatus, UserStoryStatus
|
from taiga.projects.models import IssueStatus, TaskStatus, UserStoryStatus
|
||||||
|
@ -27,11 +27,10 @@ from taiga.projects.history.services import take_snapshot
|
||||||
from taiga.projects.notifications.services import send_notifications
|
from taiga.projects.notifications.services import send_notifications
|
||||||
from taiga.hooks.event_hooks import BaseEventHook
|
from taiga.hooks.event_hooks import BaseEventHook
|
||||||
from taiga.hooks.exceptions import ActionSyntaxException
|
from taiga.hooks.exceptions import ActionSyntaxException
|
||||||
|
from taiga.base.utils import json
|
||||||
|
|
||||||
from .services import get_bitbucket_user
|
from .services import get_bitbucket_user
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
|
|
||||||
class PushEventHook(BaseEventHook):
|
class PushEventHook(BaseEventHook):
|
||||||
def process_event(self):
|
def process_event(self):
|
||||||
|
@ -92,7 +91,7 @@ class PushEventHook(BaseEventHook):
|
||||||
element.save()
|
element.save()
|
||||||
|
|
||||||
snapshot = take_snapshot(element,
|
snapshot = take_snapshot(element,
|
||||||
comment="Status changed from BitBucket commit",
|
comment=_("Status changed from BitBucket commit"),
|
||||||
user=get_bitbucket_user(bitbucket_user))
|
user=get_bitbucket_user(bitbucket_user))
|
||||||
send_notifications(element, history=snapshot)
|
send_notifications(element, history=snapshot)
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from taiga.projects.models import IssueStatus, TaskStatus, UserStoryStatus
|
from taiga.projects.models import IssueStatus, TaskStatus, UserStoryStatus
|
||||||
|
|
||||||
|
@ -85,7 +85,7 @@ class PushEventHook(BaseEventHook):
|
||||||
element.save()
|
element.save()
|
||||||
|
|
||||||
snapshot = take_snapshot(element,
|
snapshot = take_snapshot(element,
|
||||||
comment="Status changed from GitHub commit",
|
comment=_("Status changed from GitHub commit"),
|
||||||
user=get_github_user(github_user))
|
user=get_github_user(github_user))
|
||||||
send_notifications(element, history=snapshot)
|
send_notifications(element, history=snapshot)
|
||||||
|
|
||||||
|
@ -125,7 +125,7 @@ class IssuesEventHook(BaseEventHook):
|
||||||
)
|
)
|
||||||
take_snapshot(issue, user=get_github_user(github_user))
|
take_snapshot(issue, user=get_github_user(github_user))
|
||||||
|
|
||||||
snapshot = take_snapshot(issue, comment="Created from GitHub", user=get_github_user(github_user))
|
snapshot = take_snapshot(issue, comment=_("Created from GitHub"), user=get_github_user(github_user))
|
||||||
send_notifications(issue, history=snapshot)
|
send_notifications(issue, history=snapshot)
|
||||||
|
|
||||||
|
|
||||||
|
@ -149,6 +149,6 @@ class IssueCommentEventHook(BaseEventHook):
|
||||||
|
|
||||||
for item in list(issues) + list(tasks) + list(uss):
|
for item in list(issues) + list(tasks) + list(uss):
|
||||||
snapshot = take_snapshot(item,
|
snapshot = take_snapshot(item,
|
||||||
comment="From GitHub:\n\n{}".format(comment_message),
|
comment=_("From GitHub:\n\n{}".format(comment_message)),
|
||||||
user=get_github_user(github_user))
|
user=get_github_user(github_user))
|
||||||
send_notifications(item, history=snapshot)
|
send_notifications(item, history=snapshot)
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from taiga.projects.models import IssueStatus, TaskStatus, UserStoryStatus
|
from taiga.projects.models import IssueStatus, TaskStatus, UserStoryStatus
|
||||||
|
|
||||||
|
@ -84,7 +84,7 @@ class PushEventHook(BaseEventHook):
|
||||||
element.save()
|
element.save()
|
||||||
|
|
||||||
snapshot = take_snapshot(element,
|
snapshot = take_snapshot(element,
|
||||||
comment="Status changed from GitLab commit",
|
comment=_("Status changed from GitLab commit"),
|
||||||
user=get_gitlab_user(gitlab_user))
|
user=get_gitlab_user(gitlab_user))
|
||||||
send_notifications(element, history=snapshot)
|
send_notifications(element, history=snapshot)
|
||||||
|
|
||||||
|
@ -126,5 +126,5 @@ class IssuesEventHook(BaseEventHook):
|
||||||
)
|
)
|
||||||
take_snapshot(issue, user=get_gitlab_user(None))
|
take_snapshot(issue, user=get_gitlab_user(None))
|
||||||
|
|
||||||
snapshot = take_snapshot(issue, comment="Created from GitLab", user=get_gitlab_user(None))
|
snapshot = take_snapshot(issue, comment=_("Created from GitLab"), user=get_gitlab_user(None))
|
||||||
send_notifications(issue, history=snapshot)
|
send_notifications(issue, history=snapshot)
|
||||||
|
|
|
@ -0,0 +1,30 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from taiga.base import response
|
||||||
|
from taiga.base.api.viewsets import ReadOnlyListViewSet
|
||||||
|
|
||||||
|
from . import permissions
|
||||||
|
|
||||||
|
|
||||||
|
class LocalesViewSet(ReadOnlyListViewSet):
|
||||||
|
permission_classes = (permissions.LocalesPermission,)
|
||||||
|
|
||||||
|
def list(self, request, *args, **kwargs):
|
||||||
|
locales = [{"code": c, "name": n} for c, n in settings.LANGUAGES]
|
||||||
|
return response.Ok(locales)
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,21 @@
|
||||||
|
# Copyright (C) 2015 Andrey Antukh <niwi@niwi.be>
|
||||||
|
# Copyright (C) 2015 Jesús Espino <jespinog@gmail.com>
|
||||||
|
# Copyright (C) 2015 David Barragán <bameda@dbarragan.com>
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU Affero General Public License as
|
||||||
|
# published by the Free Software Foundation, either version 3 of the
|
||||||
|
# License, or (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU Affero General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from taiga.base.api.permissions import TaigaResourcePermission, AllowAny
|
||||||
|
|
||||||
|
|
||||||
|
class LocalesPermission(TaigaResourcePermission):
|
||||||
|
global_perms = AllowAny()
|
|
@ -18,7 +18,7 @@ import uuid
|
||||||
|
|
||||||
from django.db.models import signals
|
from django.db.models import signals
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from taiga.base import filters
|
from taiga.base import filters
|
||||||
from taiga.base import response
|
from taiga.base import response
|
||||||
|
@ -186,10 +186,10 @@ class ProjectViewSet(ModelCrudViewSet):
|
||||||
template_description = request.DATA.get('template_description', None)
|
template_description = request.DATA.get('template_description', None)
|
||||||
|
|
||||||
if not template_name:
|
if not template_name:
|
||||||
raise response.BadRequest("Not valid template name")
|
raise response.BadRequest(_("Not valid template name"))
|
||||||
|
|
||||||
if not template_description:
|
if not template_description:
|
||||||
raise response.BadRequest("Not valid template description")
|
raise response.BadRequest(_("Not valid template description"))
|
||||||
|
|
||||||
template_slug = slugify_uniquely(template_name, models.ProjectTemplate)
|
template_slug = slugify_uniquely(template_name, models.ProjectTemplate)
|
||||||
|
|
||||||
|
|
|
@ -14,24 +14,18 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import os
|
|
||||||
import os.path as path
|
import os.path as path
|
||||||
import hashlib
|
|
||||||
import mimetypes
|
import mimetypes
|
||||||
mimetypes.init()
|
mimetypes.init()
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.conf import settings
|
|
||||||
from django import http
|
|
||||||
|
|
||||||
from taiga.base import filters
|
from taiga.base import filters
|
||||||
from taiga.base import exceptions as exc
|
from taiga.base import exceptions as exc
|
||||||
from taiga.base.api import generics
|
|
||||||
from taiga.base.api import ModelCrudViewSet
|
from taiga.base.api import ModelCrudViewSet
|
||||||
from taiga.base.api.utils import get_object_or_404
|
from taiga.base.api.utils import get_object_or_404
|
||||||
|
|
||||||
from taiga.users.models import User
|
|
||||||
|
|
||||||
from taiga.projects.notifications.mixins import WatchedResourceMixin
|
from taiga.projects.notifications.mixins import WatchedResourceMixin
|
||||||
from taiga.projects.history.mixins import HistoryResourceMixin
|
from taiga.projects.history.mixins import HistoryResourceMixin
|
||||||
|
|
||||||
|
@ -50,7 +44,7 @@ class BaseAttachmentViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCru
|
||||||
def update(self, *args, **kwargs):
|
def update(self, *args, **kwargs):
|
||||||
partial = kwargs.get("partial", False)
|
partial = kwargs.get("partial", False)
|
||||||
if not partial:
|
if not partial:
|
||||||
raise exc.NotSupported("Non partial updates not supported")
|
raise exc.NotSupported(_("Non partial updates not supported"))
|
||||||
return super().update(*args, **kwargs)
|
return super().update(*args, **kwargs)
|
||||||
|
|
||||||
def get_content_type(self):
|
def get_content_type(self):
|
||||||
|
@ -65,7 +59,7 @@ class BaseAttachmentViewSet(HistoryResourceMixin, WatchedResourceMixin, ModelCru
|
||||||
obj.name = path.basename(obj.attached_file.name).lower()
|
obj.name = path.basename(obj.attached_file.name).lower()
|
||||||
|
|
||||||
if obj.project_id != obj.content_object.project_id:
|
if obj.project_id != obj.content_object.project_id:
|
||||||
raise exc.WrongArguments("Project ID not matches between object and project")
|
raise exc.WrongArguments(_("Project ID not matches between object and project"))
|
||||||
|
|
||||||
super().pre_save(obj)
|
super().pre_save(obj)
|
||||||
|
|
||||||
|
|
|
@ -19,9 +19,8 @@ import hashlib
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
from rest_framework import serializers
|
from taiga.base.api import serializers
|
||||||
|
|
||||||
from taiga.base.serializers import ModelSerializer
|
|
||||||
from taiga.base.utils.urls import reverse
|
from taiga.base.utils.urls import reverse
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
|
@ -14,7 +14,10 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
|
|
||||||
VIDEOCONFERENCES_CHOICES = (
|
VIDEOCONFERENCES_CHOICES = (
|
||||||
("appear-in", "AppearIn"),
|
("appear-in", _("AppearIn")),
|
||||||
("talky", "Talky"),
|
("talky", _("Talky")),
|
||||||
)
|
)
|
||||||
|
|
|
@ -78,7 +78,7 @@ class IssueCustomAttribute(AbstractCustomAttribute):
|
||||||
#######################################################
|
#######################################################
|
||||||
|
|
||||||
class AbstractCustomAttributesValues(OCCModelMixin, models.Model):
|
class AbstractCustomAttributesValues(OCCModelMixin, models.Model):
|
||||||
attributes_values = JsonField(null=False, blank=False, default={}, verbose_name=_("attributes_values"))
|
attributes_values = JsonField(null=False, blank=False, default={}, verbose_name=_("values"))
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
abstract = True
|
abstract = True
|
||||||
|
|
|
@ -18,10 +18,9 @@
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext_lazy as _
|
||||||
|
|
||||||
from rest_framework.serializers import ValidationError
|
from taiga.base.fields import JsonField
|
||||||
|
from taiga.base.api.serializers import ValidationError
|
||||||
from taiga.base.serializers import ModelSerializer
|
from taiga.base.api.serializers import ModelSerializer
|
||||||
from taiga.base.serializers import JsonField
|
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
|
@ -15,6 +15,7 @@
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
from django.utils.translation import ugettext as _
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from taiga.base import response
|
from taiga.base import response
|
||||||
|
@ -66,7 +67,7 @@ class HistoryViewSet(ReadOnlyListViewSet):
|
||||||
return response.NotFound()
|
return response.NotFound()
|
||||||
|
|
||||||
if comment.delete_comment_date or comment.delete_comment_user:
|
if comment.delete_comment_date or comment.delete_comment_user:
|
||||||
return response.BadRequest({"error": "Comment already deleted"})
|
return response.BadRequest({"error": _("Comment already deleted")})
|
||||||
|
|
||||||
comment.delete_comment_date = timezone.now()
|
comment.delete_comment_date = timezone.now()
|
||||||
comment.delete_comment_user = {"pk": request.user.pk, "name": request.user.get_full_name()}
|
comment.delete_comment_user = {"pk": request.user.pk, "name": request.user.get_full_name()}
|
||||||
|
@ -85,7 +86,7 @@ class HistoryViewSet(ReadOnlyListViewSet):
|
||||||
return response.NotFound()
|
return response.NotFound()
|
||||||
|
|
||||||
if not comment.delete_comment_date and not comment.delete_comment_user:
|
if not comment.delete_comment_date and not comment.delete_comment_user:
|
||||||
return response.BadRequest({"error": "Comment not deleted"})
|
return response.BadRequest({"error": _("Comment not deleted")})
|
||||||
|
|
||||||
comment.delete_comment_date = None
|
comment.delete_comment_date = None
|
||||||
comment.delete_comment_user = None
|
comment.delete_comment_user = None
|
||||||
|
|
|
@ -33,7 +33,7 @@ class HistoryResourceMixin(object):
|
||||||
|
|
||||||
def get_last_history(self):
|
def get_last_history(self):
|
||||||
if not self.__object_saved:
|
if not self.__object_saved:
|
||||||
message = ("get_last_history() function called before any object are saved. "
|
message = ("get_last_history() function called before any object are saved. "
|
||||||
"Seems you have a wrong mixing order on your resource.")
|
"Seems you have a wrong mixing order on your resource.")
|
||||||
warnings.warn(message, RuntimeWarning)
|
warnings.warn(message, RuntimeWarning)
|
||||||
return self.__last_history
|
return self.__last_history
|
||||||
|
|
|
@ -14,20 +14,20 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from rest_framework import serializers
|
from taiga.base.api import serializers
|
||||||
from taiga.base.serializers import JsonField
|
from taiga.base.fields import JsonField, I18NJsonField
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
HISTORY_ENTRY_I18N_FIELDS=("points", "status", "severity", "priority", "type")
|
||||||
|
|
||||||
class HistoryEntrySerializer(serializers.ModelSerializer):
|
class HistoryEntrySerializer(serializers.ModelSerializer):
|
||||||
diff = JsonField()
|
diff = JsonField()
|
||||||
snapshot = JsonField()
|
snapshot = JsonField()
|
||||||
values = JsonField()
|
values = I18NJsonField(i18n_fields=HISTORY_ENTRY_I18N_FIELDS)
|
||||||
values_diff = JsonField()
|
values_diff = I18NJsonField(i18n_fields=HISTORY_ENTRY_I18N_FIELDS)
|
||||||
user = JsonField()
|
user = JsonField()
|
||||||
delete_comment_user = JsonField()
|
delete_comment_user = JsonField()
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.HistoryEntry
|
model = models.HistoryEntry
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.http import Http404, HttpResponse
|
from django.http import Http404, HttpResponse
|
||||||
|
|
||||||
|
|
|
@ -14,10 +14,11 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
from rest_framework import serializers
|
from taiga.base.api import serializers
|
||||||
|
from taiga.base.fields import TagsField
|
||||||
|
from taiga.base.fields import PgArrayField
|
||||||
|
from taiga.base.neighbors import NeighborsSerializerMixin
|
||||||
|
|
||||||
from taiga.base.serializers import (Serializer, TagsField, NeighborsSerializerMixin,
|
|
||||||
PgArrayField, ModelSerializer)
|
|
||||||
|
|
||||||
from taiga.mdrender.service import render as mdrender
|
from taiga.mdrender.service import render as mdrender
|
||||||
from taiga.projects.validators import ProjectExistsValidator
|
from taiga.projects.validators import ProjectExistsValidator
|
||||||
|
@ -26,7 +27,7 @@ from taiga.projects.notifications.validators import WatchersValidator
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
class IssueSerializer(WatchersValidator, ModelSerializer):
|
class IssueSerializer(WatchersValidator, serializers.ModelSerializer):
|
||||||
tags = TagsField(required=False)
|
tags = TagsField(required=False)
|
||||||
external_reference = PgArrayField(required=False)
|
external_reference = PgArrayField(required=False)
|
||||||
is_closed = serializers.Field(source="is_closed")
|
is_closed = serializers.Field(source="is_closed")
|
||||||
|
@ -63,13 +64,13 @@ class IssueNeighborsSerializer(NeighborsSerializerMixin, IssueSerializer):
|
||||||
return NeighborIssueSerializer(neighbor).data
|
return NeighborIssueSerializer(neighbor).data
|
||||||
|
|
||||||
|
|
||||||
class NeighborIssueSerializer(ModelSerializer):
|
class NeighborIssueSerializer(serializers.ModelSerializer):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = models.Issue
|
model = models.Issue
|
||||||
fields = ("id", "ref", "subject")
|
fields = ("id", "ref", "subject")
|
||||||
depth = 0
|
depth = 0
|
||||||
|
|
||||||
|
|
||||||
class IssuesBulkSerializer(ProjectExistsValidator, Serializer):
|
class IssuesBulkSerializer(ProjectExistsValidator, serializers.Serializer):
|
||||||
project_id = serializers.IntegerField()
|
project_id = serializers.IntegerField()
|
||||||
bulk_issues = serializers.CharField()
|
bulk_issues = serializers.CharField()
|
||||||
|
|
|
@ -72,7 +72,7 @@ class Milestone(WatchedModelMixin, models.Model):
|
||||||
def clean(self):
|
def clean(self):
|
||||||
# Don't allow draft entries to have a pub_date.
|
# Don't allow draft entries to have a pub_date.
|
||||||
if self.estimated_start and self.estimated_finish and self.estimated_start > self.estimated_finish:
|
if self.estimated_start and self.estimated_finish and self.estimated_start > self.estimated_finish:
|
||||||
raise ValidationError('The estimated start must be previous to the estimated finish.')
|
raise ValidationError(_('The estimated start must be previous to the estimated finish.'))
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
if not self._importing or not self.modified_date:
|
if not self._importing or not self.modified_date:
|
||||||
|
|
|
@ -14,15 +14,16 @@
|
||||||
# You should have received a copy of the GNU Affero General Public License
|
# You should have received a copy of the GNU Affero General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
import json
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from rest_framework import serializers
|
from taiga.base.api import serializers
|
||||||
|
|
||||||
|
from taiga.base.utils import json
|
||||||
|
|
||||||
from ..userstories.serializers import UserStorySerializer
|
from ..userstories.serializers import UserStorySerializer
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
class MilestoneSerializer(serializers.ModelSerializer):
|
class MilestoneSerializer(serializers.ModelSerializer):
|
||||||
user_stories = UserStorySerializer(many=True, required=False, read_only=True)
|
user_stories = UserStorySerializer(many=True, required=False, read_only=True)
|
||||||
total_points = serializers.SerializerMethodField("get_total_points")
|
total_points = serializers.SerializerMethodField("get_total_points")
|
||||||
|
@ -59,6 +60,6 @@ class MilestoneSerializer(serializers.ModelSerializer):
|
||||||
qs = models.Milestone.objects.filter(project=attrs["project"], name=attrs[source])
|
qs = models.Milestone.objects.filter(project=attrs["project"], name=attrs[source])
|
||||||
|
|
||||||
if qs and qs.exists():
|
if qs and qs.exists():
|
||||||
raise serializers.ValidationError("Name duplicated for the project")
|
raise serializers.ValidationError(_("Name duplicated for the project"))
|
||||||
|
|
||||||
return attrs
|
return attrs
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from django.utils.translation import ugettext_lazy as _
|
from django.utils.translation import ugettext as _
|
||||||
|
|
||||||
from rest_framework import serializers
|
from taiga.base.api import serializers
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue