commit
a3547db978
|
@ -5,6 +5,7 @@ settings/local.py
|
|||
database.sqlite
|
||||
logs
|
||||
media
|
||||
static
|
||||
*.pyc
|
||||
*.mo
|
||||
.venv
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
[main]
|
||||
host = https://www.transifex.com
|
||||
lang_map = sr@latin:sr_Latn, zh_CN:zh_Hans, zh_TW:zh_Hant
|
||||
|
||||
[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
|
21
CHANGELOG.md
21
CHANGELOG.md
|
@ -1,8 +1,27 @@
|
|||
# Changelog #
|
||||
|
||||
|
||||
## 1.6.0 Abies Bifolia (2015-03-17)
|
||||
## 1.7.0 Empetrum Nigrum (unreleased)
|
||||
|
||||
### Features
|
||||
- Make Taiga translatable (i18n support).
|
||||
- i18n.
|
||||
- Add spanish (es) translation.
|
||||
- Add french (fr) translation.
|
||||
- Add finish (fi) translation.
|
||||
- Add catalan (ca) translation.
|
||||
- Add traditional chinese (zh-Hant) translation.
|
||||
- Add Jitsi to our supported videoconference apps list
|
||||
- Add tags field to CSV reports.
|
||||
- Improve history (and email) comments created by all the GitHub actions
|
||||
|
||||
### Misc
|
||||
- New contrib plugin for letschat (by Δndrea Stagi)
|
||||
- Remove djangorestframework from requirements. Move useful code to core.
|
||||
- Lots of small and not so small bugfixes.
|
||||
|
||||
|
||||
## 1.6.0 Abies Bifolia (2015-03-17)
|
||||
|
||||
### Features
|
||||
- Added custom fields per project for user stories, tasks and issues.
|
||||
|
|
|
@ -10,4 +10,4 @@ coverage==3.7.1
|
|||
coveralls==0.4.2
|
||||
django-slowdown==0.0.1
|
||||
|
||||
taiga-contrib-github-auth==0.0.3
|
||||
transifex-client==0.11.1.beta
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
djangorestframework==2.3.13
|
||||
Django==1.7.6
|
||||
Django==1.7.8
|
||||
#djangorestframework==2.3.13 # It's not necessary since Taiga 1.7
|
||||
django-picklefield==0.3.1
|
||||
django-sampledatahelper==0.2.2
|
||||
gunicorn==19.1.1
|
||||
|
@ -8,7 +8,7 @@ pillow==2.5.3
|
|||
pytz==2014.4
|
||||
six==1.8.0
|
||||
amqp==1.4.6
|
||||
djmail==0.9
|
||||
djmail==0.10
|
||||
django-pgjson==0.2.2
|
||||
djorm-pgarray==1.0.4
|
||||
django-jinja==1.0.4
|
||||
|
|
|
@ -0,0 +1,283 @@
|
|||
#!/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
|
||||
#
|
||||
# * commit: update resources in transifex.com with the local files
|
||||
#
|
||||
# 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 = []
|
||||
for d in os.listdir(dir_):
|
||||
if not d.startswith('_') and os.path.isdir(os.path.join(dir_, d)):
|
||||
langs.append(d)
|
||||
langs = sorted(langs)
|
||||
|
||||
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} -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 os.path.isdir(os.path.join(dir_, d)) 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 regenerate(resources=None, languages=None):
|
||||
"""
|
||||
Wrap long lines and generate mo files.
|
||||
"""
|
||||
locale_dirs = _get_locale_dirs(resources)
|
||||
errors = []
|
||||
|
||||
for name, dir_ in locale_dirs:
|
||||
if languages is None:
|
||||
languages = sorted([d for d in os.listdir(dir_) if not d.startswith("_") and os.path.isdir(os.path.join(dir_, d)) and d != "en"])
|
||||
|
||||
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.1.beta
|
||||
|
||||
2. Create ~/.transifexrc file:
|
||||
|
||||
$ vim ~/.transifexrc"
|
||||
|
||||
[https://www.transifex.com]
|
||||
hostname = https://www.transifex.com
|
||||
token =
|
||||
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.",
|
||||
"regenerate": "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,6 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import os.path, sys, os
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(__file__))
|
||||
|
||||
|
@ -26,11 +25,6 @@ ADMINS = (
|
|||
("Admin", "example@example.com"),
|
||||
)
|
||||
|
||||
LANGUAGES = (
|
||||
("en", _("English")),
|
||||
("es", _("Spanish")),
|
||||
)
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "transaction_hooks.backends.postgresql_psycopg2",
|
||||
|
@ -60,12 +54,111 @@ IGNORABLE_404_STARTS = ("/phpmyadmin/",)
|
|||
|
||||
ATOMIC_REQUESTS = True
|
||||
TIME_ZONE = "UTC"
|
||||
LANGUAGE_CODE = "en"
|
||||
USE_I18N = True
|
||||
USE_L10N = True
|
||||
LOGIN_URL="/auth/login/"
|
||||
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"]
|
||||
|
||||
LOCALE_PATHS = (
|
||||
os.path.join(BASE_DIR, "locale"),
|
||||
os.path.join(BASE_DIR, "taiga", "locale"),
|
||||
)
|
||||
|
||||
SITES = {
|
||||
"api": {"domain": "localhost:8000", "scheme": "http", "name": "api"},
|
||||
"front": {"domain": "localhost:9001", "scheme": "http", "name": "front"},
|
||||
|
@ -102,13 +195,11 @@ MEDIA_URL = "http://localhost:8000/media/"
|
|||
# Static url is not widelly used by taiga (only
|
||||
# if admin is activated).
|
||||
STATIC_URL = "http://localhost:8000/static/"
|
||||
ADMIN_MEDIA_PREFIX = "http://localhost:8000/static/admin/"
|
||||
|
||||
# Static configuration.
|
||||
MEDIA_ROOT = os.path.join(BASE_DIR, "media")
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, "static")
|
||||
|
||||
|
||||
STATICFILES_FINDERS = [
|
||||
"django.contrib.staticfiles.finders.FileSystemFinder",
|
||||
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
|
||||
|
@ -119,15 +210,9 @@ STATICFILES_DIRS = (
|
|||
# Don't forget to use absolute paths, not relative paths.
|
||||
)
|
||||
|
||||
|
||||
# Defautl storage
|
||||
DEFAULT_FILE_STORAGE = "taiga.base.storage.FileSystemStorage"
|
||||
|
||||
|
||||
LOCALE_PATHS = (
|
||||
os.path.join(BASE_DIR, "locale"),
|
||||
)
|
||||
|
||||
SECRET_KEY = "aw3+t2r(8(0kkrhg8)gx6i96v5^kv%6cfep9wxfom0%7dy0m9e"
|
||||
|
||||
TEMPLATE_LOADERS = [
|
||||
|
@ -174,6 +259,8 @@ INSTALLED_APPS = [
|
|||
"django.contrib.staticfiles",
|
||||
|
||||
"taiga.base",
|
||||
"taiga.base.api",
|
||||
"taiga.locale",
|
||||
"taiga.events",
|
||||
"taiga.front",
|
||||
"taiga.users",
|
||||
|
@ -200,7 +287,6 @@ INSTALLED_APPS = [
|
|||
"taiga.hooks.bitbucket",
|
||||
"taiga.webhooks",
|
||||
|
||||
"rest_framework",
|
||||
"djmail",
|
||||
"django_jinja",
|
||||
"django_jinja.contrib._humanize",
|
||||
|
@ -269,6 +355,7 @@ LOGGING = {
|
|||
|
||||
AUTH_USER_MODEL = "users.User"
|
||||
FORMAT_MODULE_PATH = "taiga.base.formats"
|
||||
|
||||
DATE_INPUT_FORMATS = (
|
||||
"%Y-%m-%d", "%m/%d/%Y", "%d/%m/%Y", "%b %d %Y",
|
||||
"%b %d, %Y", "%d %b %Y", "%d %b, %Y", "%B %d %Y",
|
||||
|
|
|
@ -61,4 +61,3 @@ DATABASES = {
|
|||
#GITHUB_API_URL = "https://api.github.com/"
|
||||
#GITHUB_API_CLIENT_ID = "yourgithubclientid"
|
||||
#GITHUB_API_CLIENT_SECRET = "yourgithubclientsecret"
|
||||
|
||||
|
|
|
@ -17,11 +17,10 @@
|
|||
from functools import partial
|
||||
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 rest_framework import serializers
|
||||
|
||||
from taiga.base.api import serializers
|
||||
from taiga.base.api import viewsets
|
||||
from taiga.base.decorators import list_route
|
||||
from taiga.base import exceptions as exc
|
||||
|
|
|
@ -35,7 +35,7 @@ fraudulent modifications.
|
|||
import re
|
||||
|
||||
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
|
||||
|
||||
|
@ -43,7 +43,7 @@ from .tokens import get_user_for_token
|
|||
class Session(BaseAuthentication):
|
||||
"""
|
||||
Session based authentication like the standard
|
||||
`rest_framework.authentication.SessionAuthentication`
|
||||
`taiga.base.api.authentication.SessionAuthentication`
|
||||
but with csrf disabled (for obvious reasons because
|
||||
it is for api.
|
||||
|
||||
|
|
|
@ -14,10 +14,12 @@
|
|||
# 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 rest_framework import serializers
|
||||
|
||||
from django.core import validators
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from taiga.base.api import serializers
|
||||
|
||||
import re
|
||||
|
||||
|
||||
|
@ -29,13 +31,13 @@ class BaseRegisterSerializer(serializers.Serializer):
|
|||
|
||||
def validate_username(self, attrs, source):
|
||||
value = attrs[source]
|
||||
validator = validators.RegexValidator(re.compile('^[\w.-]+$'), "invalid username", "invalid")
|
||||
validator = validators.RegexValidator(re.compile('^[\w.-]+$'), _("invalid username"), "invalid")
|
||||
|
||||
try:
|
||||
validator(value)
|
||||
except ValidationError:
|
||||
raise serializers.ValidationError("Required. 255 characters or fewer. Letters, numbers "
|
||||
"and /./-/_ characters'")
|
||||
raise serializers.ValidationError(_("Required. 255 characters or fewer. Letters, numbers "
|
||||
"and /./-/_ characters'"))
|
||||
return attrs
|
||||
|
||||
|
||||
|
|
|
@ -58,7 +58,7 @@ def send_register_email(user) -> bool:
|
|||
cancel_token = get_token_for_user(user, "cancel_account")
|
||||
context = {"user": user, "cancel_token": cancel_token}
|
||||
mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail)
|
||||
email = mbuilder.registered_user(user.email, context)
|
||||
email = mbuilder.registered_user(user, context)
|
||||
return bool(email.send())
|
||||
|
||||
|
||||
|
@ -91,7 +91,7 @@ def get_membership_by_token(token:str):
|
|||
membership_model = apps.get_model("projects", "Membership")
|
||||
qs = membership_model.objects.filter(token=token)
|
||||
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]
|
||||
|
||||
|
||||
|
@ -119,7 +119,7 @@ def public_register(username:str, password:str, email:str, full_name:str):
|
|||
try:
|
||||
user.save()
|
||||
except IntegrityError:
|
||||
raise exc.WrongArguments("User is already register.")
|
||||
raise exc.WrongArguments(_("User is already registered."))
|
||||
|
||||
send_register_email(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.save(update_fields=["user"])
|
||||
except IntegrityError:
|
||||
raise exc.IntegrityError("Membership with user is already exists.")
|
||||
raise exc.IntegrityError(_("Membership with user is already exists."))
|
||||
|
||||
send_register_email(user)
|
||||
return user
|
||||
|
|
|
@ -18,6 +18,7 @@ from taiga.base import exceptions as exc
|
|||
|
||||
from django.apps import apps
|
||||
from django.core import signing
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
|
||||
def get_token_for_user(user, scope):
|
||||
|
@ -43,13 +44,13 @@ def get_user_for_token(token, scope, max_age=None):
|
|||
try:
|
||||
data = signing.loads(token, max_age=max_age)
|
||||
except signing.BadSignature:
|
||||
raise exc.NotAuthenticated("Invalid token")
|
||||
raise exc.NotAuthenticated(_("Invalid token"))
|
||||
|
||||
model_cls = apps.get_model("users", "User")
|
||||
|
||||
try:
|
||||
user = model_cls.objects.get(pk=data["user_%s_id" % (scope)])
|
||||
except (model_cls.DoesNotExist, KeyError):
|
||||
raise exc.NotAuthenticated("Invalid token")
|
||||
raise exc.NotAuthenticated(_("Invalid token"))
|
||||
else:
|
||||
return user
|
||||
|
|
|
@ -17,6 +17,15 @@
|
|||
# This code is partially taken from django-rest-framework:
|
||||
# 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 ModelCrudViewSet
|
||||
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:
|
||||
# Copyright (c) 2011-2014, Tom Christie
|
||||
|
||||
import warnings
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.paginator import Paginator, InvalidPage
|
||||
from django.http import Http404
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from . import views
|
||||
from . import mixins
|
||||
from . import pagination
|
||||
from .settings import api_settings
|
||||
from .utils import get_object_or_404
|
||||
|
||||
|
||||
def strict_positive_int(integer_string, cutoff=None):
|
||||
"""
|
||||
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):
|
||||
class GenericAPIView(pagination.PaginationMixin,
|
||||
views.APIView):
|
||||
"""
|
||||
Base class for all other generic views.
|
||||
"""
|
||||
|
@ -63,20 +48,12 @@ class GenericAPIView(views.APIView):
|
|||
lookup_field = 'pk'
|
||||
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
|
||||
filter_backends = api_settings.DEFAULT_FILTER_BACKENDS
|
||||
|
||||
# The following attributes may be subject to change,
|
||||
# and should be considered private API.
|
||||
model_serializer_class = api_settings.DEFAULT_MODEL_SERIALIZER_CLASS
|
||||
paginator_class = Paginator
|
||||
|
||||
######################################
|
||||
# These are pending deprecation...
|
||||
|
@ -107,70 +84,6 @@ class GenericAPIView(views.APIView):
|
|||
return serializer_class(instance, data=data, files=files,
|
||||
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):
|
||||
"""
|
||||
|
@ -202,29 +115,6 @@ class GenericAPIView(views.APIView):
|
|||
# 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):
|
||||
if self.action == "list" and hasattr(self, "list_serializer_class"):
|
||||
return self.list_serializer_class
|
||||
|
@ -233,11 +123,9 @@ class GenericAPIView(views.APIView):
|
|||
if serializer_class is not None:
|
||||
return serializer_class
|
||||
|
||||
assert self.model is not None, \
|
||||
"'%s' should either include a 'serializer_class' attribute, " \
|
||||
"or use the 'model' attribute as a shortcut for " \
|
||||
"automatically generating a serializer class." \
|
||||
% self.__class__.__name__
|
||||
assert self.model is not None, ("'%s' should either include a 'serializer_class' attribute, "
|
||||
"or use the 'model' attribute as a shortcut for "
|
||||
"automatically generating a serializer class." % self.__class__.__name__)
|
||||
|
||||
class DefaultSerializer(self.model_serializer_class):
|
||||
class Meta:
|
||||
|
@ -261,7 +149,7 @@ class GenericAPIView(views.APIView):
|
|||
if self.model is not None:
|
||||
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):
|
||||
"""
|
||||
|
@ -289,18 +177,16 @@ class GenericAPIView(views.APIView):
|
|||
if lookup is not None:
|
||||
filter_kwargs = {self.lookup_field: lookup}
|
||||
elif pk is not None and self.lookup_field == 'pk':
|
||||
raise RuntimeError('The `pk_url_kwarg` attribute is due to be deprecated. '
|
||||
'Use the `lookup_field` attribute instead')
|
||||
raise RuntimeError(('The `pk_url_kwarg` attribute is due to be deprecated. '
|
||||
'Use the `lookup_field` attribute instead'))
|
||||
elif slug is not None and self.lookup_field == 'pk':
|
||||
raise RuntimeError('The `slug_url_kwarg` attribute is due to be deprecated. '
|
||||
'Use the `lookup_field` attribute instead')
|
||||
raise RuntimeError(('The `slug_url_kwarg` attribute is due to be deprecated. '
|
||||
'Use the `lookup_field` attribute instead'))
|
||||
else:
|
||||
raise ImproperlyConfigured(
|
||||
'Expected view %s to be called with a URL keyword argument '
|
||||
'named "%s". Fix your URL conf, or set the `.lookup_field` '
|
||||
'attribute on the view correctly.' %
|
||||
(self.__class__.__name__, self.lookup_field)
|
||||
)
|
||||
raise ImproperlyConfigured(('Expected view %s to be called with a URL keyword argument '
|
||||
'named "%s". Fix your URL conf, or set the `.lookup_field` '
|
||||
'attribute on the view correctly.' %
|
||||
(self.__class__.__name__, self.lookup_field)))
|
||||
|
||||
obj = get_object_or_404(queryset, **filter_kwargs)
|
||||
return obj
|
||||
|
|
|
@ -22,10 +22,11 @@ import warnings
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.http import Http404
|
||||
from django.db import transaction as tx
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from taiga.base import response
|
||||
from rest_framework.settings import api_settings
|
||||
|
||||
from .settings import api_settings
|
||||
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
|
||||
# `.allow_empty = False`, to raise 404 errors on empty querysets.
|
||||
if not self.allow_empty and not self.object_list:
|
||||
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
|
||||
)
|
||||
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)
|
||||
class_name = self.__class__.__name__
|
||||
error_msg = self.empty_error % {'class_name': class_name}
|
||||
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
|
||||
# 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 get_paginate_by(self, *args, **kwargs):
|
||||
def strict_positive_int(integer_string, cutoff=None):
|
||||
"""
|
||||
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:
|
||||
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):
|
||||
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:
|
||||
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 django.apps import apps
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
######################################################################
|
||||
# Base permissiones definition
|
||||
|
@ -57,7 +58,7 @@ class ResourcePermission(object):
|
|||
elif inspect.isclass(permset) and issubclass(permset, PermissionComponent):
|
||||
permset = permset()
|
||||
else:
|
||||
raise RuntimeError("Invalid permission definition.")
|
||||
raise RuntimeError(_("Invalid permission definition."))
|
||||
|
||||
if self.global_perms:
|
||||
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
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
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.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 rest_framework.compat import smart_text, HttpResponseBase, View
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.settings import api_settings
|
||||
from rest_framework.utils import formatting
|
||||
from .request import Request
|
||||
from .settings import api_settings
|
||||
from .utils import formatting
|
||||
|
||||
from taiga.base import status
|
||||
from taiga.base import exceptions
|
||||
from taiga.base.response import Response
|
||||
from taiga.base.response import Ok
|
||||
from taiga.base.response import NotFound
|
||||
from taiga.base.response import Forbidden
|
||||
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):
|
||||
|
@ -93,10 +97,10 @@ def exception_handler(exc):
|
|||
headers=headers)
|
||||
|
||||
elif isinstance(exc, Http404):
|
||||
return NotFound({'detail': 'Not found'})
|
||||
return NotFound({'detail': _('Not found')})
|
||||
|
||||
elif isinstance(exc, PermissionDenied):
|
||||
return Forbidden({'detail': 'Permission denied'})
|
||||
return Forbidden({'detail': _('Permission denied')})
|
||||
|
||||
# Note: Unhandled exceptions will raise a 500 error.
|
||||
return None
|
||||
|
@ -292,7 +296,7 @@ class APIView(View):
|
|||
"""
|
||||
request.user
|
||||
|
||||
def check_permissions(self, request, action, obj=None):
|
||||
def check_permissions(self, request, action:str=None, obj=None):
|
||||
if action is None:
|
||||
self.permission_denied(request)
|
||||
|
||||
|
@ -345,11 +349,9 @@ class APIView(View):
|
|||
Returns the final response object.
|
||||
"""
|
||||
# Make the error obvious if a proper response is not returned
|
||||
assert isinstance(response, HttpResponseBase), (
|
||||
'Expected a `Response`, `HttpResponse` or `HttpStreamingResponse` '
|
||||
'to be returned from the view, but received a `%s`'
|
||||
% type(response)
|
||||
)
|
||||
assert isinstance(response, HttpResponseBase), ('Expected a `Response`, `HttpResponse` or '
|
||||
'`HttpStreamingResponse` to be returned from the view, '
|
||||
'but received a `%s`' % type(response))
|
||||
|
||||
if isinstance(response, Response):
|
||||
if not getattr(request, 'accepted_renderer', None):
|
||||
|
@ -446,6 +448,6 @@ class APIView(View):
|
|||
|
||||
def api_server_error(request, *args, **kwargs):
|
||||
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)
|
||||
return server_error(request, *args, **kwargs)
|
||||
|
|
|
@ -19,11 +19,11 @@
|
|||
|
||||
from functools import update_wrapper
|
||||
from django.utils.decorators import classonlymethod
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from . import views
|
||||
from . import mixins
|
||||
from . import generics
|
||||
from . import pagination
|
||||
|
||||
|
||||
class ViewSetMixin(object):
|
||||
|
@ -57,8 +57,8 @@ class ViewSetMixin(object):
|
|||
"keyword argument to %s(). Don't do that."
|
||||
% (key, cls.__name__))
|
||||
if not hasattr(cls, key):
|
||||
raise TypeError("%s() received an invalid keyword %r" % (
|
||||
cls.__name__, key))
|
||||
raise TypeError("%s() received an invalid keyword %r"
|
||||
% (cls.__name__, key))
|
||||
|
||||
def view(request, *args, **kwargs):
|
||||
self = cls(**initkwargs)
|
||||
|
@ -125,9 +125,7 @@ class GenericViewSet(ViewSetMixin, generics.GenericAPIView):
|
|||
pass
|
||||
|
||||
|
||||
class ReadOnlyListViewSet(pagination.HeadersPaginationMixin,
|
||||
pagination.ConditionalPaginationMixin,
|
||||
GenericViewSet):
|
||||
class ReadOnlyListViewSet(GenericViewSet):
|
||||
"""
|
||||
A viewset that provides default `list()` action.
|
||||
"""
|
||||
|
@ -156,15 +154,11 @@ class ModelViewSet(mixins.CreateModelMixin,
|
|||
pass
|
||||
|
||||
|
||||
class ModelCrudViewSet(pagination.HeadersPaginationMixin,
|
||||
pagination.ConditionalPaginationMixin,
|
||||
ModelViewSet):
|
||||
class ModelCrudViewSet(ModelViewSet):
|
||||
pass
|
||||
|
||||
|
||||
class ModelListViewSet(pagination.HeadersPaginationMixin,
|
||||
pagination.ConditionalPaginationMixin,
|
||||
mixins.RetrieveModelMixin,
|
||||
class ModelListViewSet(mixins.RetrieveModelMixin,
|
||||
mixins.ListModelMixin,
|
||||
GenericViewSet):
|
||||
pass
|
||||
|
|
|
@ -13,19 +13,10 @@
|
|||
#
|
||||
# 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/>.
|
||||
import sys
|
||||
|
||||
# Patch api view for correctly return 401 responses on
|
||||
# request is authenticated instead of 403
|
||||
from django.apps import AppConfig
|
||||
from . import monkey
|
||||
|
||||
|
||||
class BaseAppConfig(AppConfig):
|
||||
name = "taiga.base"
|
||||
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
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from rest_framework import exceptions
|
||||
from rest_framework import status
|
||||
# This code is partially taken from django-rest-framework:
|
||||
# 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.utils.encoding import force_text
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
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
|
||||
default_detail = _("Unexpected error")
|
||||
|
||||
|
@ -67,7 +150,7 @@ class RequestValidationError(BadRequest):
|
|||
default_detail = _("Data validation error")
|
||||
|
||||
|
||||
class PermissionDenied(exceptions.PermissionDenied):
|
||||
class PermissionDenied(PermissionDenied):
|
||||
"""
|
||||
Compatibility subclass of restframework `PermissionDenied`
|
||||
exception.
|
||||
|
@ -86,7 +169,7 @@ class PreconditionError(BadRequest):
|
|||
default_detail = _("Precondition error")
|
||||
|
||||
|
||||
class NotAuthenticated(exceptions.NotAuthenticated):
|
||||
class NotAuthenticated(NotAuthenticated):
|
||||
"""
|
||||
Compatibility subclass of restframework `NotAuthenticated`
|
||||
exception.
|
||||
|
@ -119,7 +202,7 @@ def exception_handler(exc):
|
|||
to be raised.
|
||||
"""
|
||||
|
||||
if isinstance(exc, exceptions.APIException):
|
||||
if isinstance(exc, APIException):
|
||||
headers = {}
|
||||
if getattr(exc, "auth_header", None):
|
||||
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.db.models import Q
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
from rest_framework import filters
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from taiga.base import exceptions as exc
|
||||
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__)
|
||||
|
||||
|
||||
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 = {
|
||||
'true': True,
|
||||
'false': False,
|
||||
|
@ -60,7 +71,7 @@ class QueryParamsFilterMixin(filters.BaseFilterBackend):
|
|||
try:
|
||||
queryset = queryset.filter(**query_params)
|
||||
except ValueError:
|
||||
raise exc.BadRequest("Error in filter params types.")
|
||||
raise exc.BadRequest(_("Error in filter params types."))
|
||||
|
||||
return queryset
|
||||
|
||||
|
@ -107,7 +118,7 @@ class PermissionBasedFilterBackend(FilterBackend):
|
|||
logger.error("Filtering project diferent value than an integer: {}".format(
|
||||
request.QUERY_PARAMS["project"]
|
||||
))
|
||||
raise exc.BadRequest("'project' must be an integer value.")
|
||||
raise exc.BadRequest(_("'project' must be an integer value."))
|
||||
|
||||
qs = queryset
|
||||
|
||||
|
@ -196,7 +207,7 @@ class CanViewProjectObjFilterBackend(FilterBackend):
|
|||
logger.error("Filtering project diferent value than an integer: {}".format(
|
||||
request.QUERY_PARAMS["project"]
|
||||
))
|
||||
raise exc.BadRequest("'project' must be an integer value.")
|
||||
raise exc.BadRequest(_("'project' must be an integer value."))
|
||||
|
||||
qs = queryset
|
||||
|
||||
|
@ -250,8 +261,9 @@ class MembersFilterBackend(PermissionBasedFilterBackend):
|
|||
try:
|
||||
project_id = int(request.QUERY_PARAMS["project"])
|
||||
except:
|
||||
logger.error("Filtering project diferent value than an integer: {}".format(request.QUERY_PARAMS["project"]))
|
||||
raise exc.BadRequest("'project' must be an integer value.")
|
||||
logger.error("Filtering project diferent value than an integer: {}".format(
|
||||
request.QUERY_PARAMS["project"]))
|
||||
raise exc.BadRequest(_("'project' must be an integer value."))
|
||||
|
||||
if project_id:
|
||||
Project = apps.get_model('projects', 'Project')
|
||||
|
|
|
@ -16,6 +16,8 @@
|
|||
|
||||
import datetime
|
||||
|
||||
from optparse import make_option
|
||||
|
||||
from django.db.models.loading import get_model
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
@ -30,6 +32,11 @@ from taiga.users.models import User
|
|||
|
||||
class Command(BaseCommand):
|
||||
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'
|
||||
|
||||
def handle(self, *args, **options):
|
||||
|
@ -37,12 +44,13 @@ class Command(BaseCommand):
|
|||
print("Usage: ./manage.py test_emails <email-address>")
|
||||
return
|
||||
|
||||
locale = options.get('locale')
|
||||
test_email = args[0]
|
||||
|
||||
mbuilder = MagicMailBuilder(template_mail_cls=InlineCSSTemplateMail)
|
||||
|
||||
# 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.send()
|
||||
|
||||
|
@ -51,17 +59,18 @@ class Command(BaseCommand):
|
|||
membership.invited_by = User.objects.all().order_by("?").first()
|
||||
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.send()
|
||||
|
||||
# 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.send()
|
||||
|
||||
# Feedback
|
||||
context = {
|
||||
"lang": locale,
|
||||
"feedback_entry": {
|
||||
"full_name": "Test full name",
|
||||
"email": "test@email.com",
|
||||
|
@ -76,17 +85,18 @@ class Command(BaseCommand):
|
|||
email.send()
|
||||
|
||||
# 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.send()
|
||||
|
||||
# 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.send()
|
||||
|
||||
# Export/Import emails
|
||||
context = {
|
||||
"lang": locale,
|
||||
"user": User.objects.all().order_by("?").first(),
|
||||
"project": Project.objects.all().order_by("?").first(),
|
||||
"error_subject": "Error generating project dump",
|
||||
|
@ -95,6 +105,7 @@ class Command(BaseCommand):
|
|||
email = mbuilder.export_error(test_email, context)
|
||||
email.send()
|
||||
context = {
|
||||
"lang": locale,
|
||||
"user": User.objects.all().order_by("?").first(),
|
||||
"error_subject": "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)
|
||||
context = {
|
||||
"lang": locale,
|
||||
"url": "http://dummyurl.com",
|
||||
"user": User.objects.all().order_by("?").first(),
|
||||
"project": Project.objects.all().order_by("?").first(),
|
||||
|
@ -113,6 +125,7 @@ class Command(BaseCommand):
|
|||
email.send()
|
||||
|
||||
context = {
|
||||
"lang": locale,
|
||||
"user": User.objects.all().order_by("?").first(),
|
||||
"project": Project.objects.all().order_by("?").first(),
|
||||
}
|
||||
|
@ -139,6 +152,7 @@ class Command(BaseCommand):
|
|||
]
|
||||
|
||||
context = {
|
||||
"lang": locale,
|
||||
"project": Project.objects.all().order_by("?").first(),
|
||||
"changer": User.objects.all().order_by("?").first(),
|
||||
"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 taiga.base.api import serializers
|
||||
|
||||
Neighbor = namedtuple("Neighbor", "left right")
|
||||
|
||||
|
||||
|
@ -67,3 +69,25 @@ def get_neighbors(obj, results_set=None):
|
|||
right = None
|
||||
|
||||
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
|
||||
# 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."""
|
||||
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):
|
||||
def __init__(self, data=None, status=None, template_name=None, headers=None, exception=False,
|
||||
content_type=None):
|
||||
super(Response, self).__init__(data, status, template_name, headers, exception,
|
||||
content_type)
|
||||
class Response(SimpleTemplateResponse):
|
||||
"""
|
||||
An HttpResponse that allows its data to be rendered into
|
||||
arbitrary media types.
|
||||
"""
|
||||
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):
|
||||
|
|
|
@ -22,10 +22,10 @@ from django.conf.urls import patterns, url
|
|||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.core.urlresolvers import NoReverseMatch
|
||||
|
||||
from rest_framework import views
|
||||
from taiga.base.api import views
|
||||
from taiga.base import response
|
||||
from rest_framework.reverse import reverse
|
||||
from rest_framework.urlpatterns import format_suffix_patterns
|
||||
from taiga.base.api.reverse import reverse
|
||||
from taiga.base.api.urlpatterns import format_suffix_patterns
|
||||
|
||||
|
||||
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
|
|
@ -420,9 +420,7 @@
|
|||
<tr>
|
||||
<td valign="top" class="footerContent">
|
||||
{% block footer %}
|
||||
{% trans support_url=sr("support.url"),
|
||||
support_email=sr("support.email"),
|
||||
mailing_list_url=sr("support.mailing_list") %}
|
||||
{% trans support_url=sr("support.url"), support_email=sr("support.email"), mailing_list_url=sr("support.mailing_list") %}
|
||||
<strong>Taiga Support:</strong>
|
||||
<a href="{{ support_url }}" title="Support page" style="color: #9dce0a">{{ support_url}}</a>
|
||||
<br>
|
||||
|
|
|
@ -394,9 +394,7 @@
|
|||
<tr>
|
||||
<td valign="top" class="footerContent">
|
||||
{% block footer %}
|
||||
{% trans support_url=sr("support.url"),
|
||||
support_email=sr("support.email"),
|
||||
mailing_list_url=sr("support.mailing_list") %}
|
||||
{% trans support_url=sr("support.url"), support_email=sr("support.email"), mailing_list_url=sr("support.mailing_list") %}
|
||||
<strong>Taiga Support:</strong>
|
||||
<a href="{{ support_url }}" title="Support page" style="color: #9dce0a">{{ support_url}}</a>
|
||||
<br>
|
||||
|
|
|
@ -456,9 +456,7 @@
|
|||
<tr>
|
||||
<td valign="top" class="footerContent">
|
||||
{% block footer %}
|
||||
{% trans support_url=sr("support.url"),
|
||||
support_email=sr("support.email"),
|
||||
mailing_list_url=sr("support.mailing_list") %}
|
||||
{% trans support_url=sr("support.url"), support_email=sr("support.email"), mailing_list_url=sr("support.mailing_list") %}
|
||||
<strong>Taiga Support:</strong>
|
||||
<a href="{{ support_url }}" title="Support page" style="color: #9dce0a">{{ support_url}}</a>
|
||||
<br>
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
# 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 rest_framework import throttling
|
||||
from taiga.base.api import throttling
|
||||
|
||||
|
||||
class AnonRateThrottle(throttling.AnonRateThrottle):
|
||||
|
|
|
@ -14,10 +14,12 @@
|
|||
# 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/>.
|
||||
|
||||
import json
|
||||
from rest_framework.utils import encoders
|
||||
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):
|
||||
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
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from contextlib import contextmanager
|
||||
|
||||
|
||||
|
@ -22,7 +24,7 @@ from contextlib import contextmanager
|
|||
def without_signals(*disablers):
|
||||
for disabler in disablers:
|
||||
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.backup_receivers = signal.receivers
|
||||
|
|
|
@ -79,7 +79,7 @@ def emit_event_for_ids(ids, content_type:str, projectid:int, *,
|
|||
type:str="change", channel:str="events", sessionid:str=None):
|
||||
assert type in set(["create", "change", "delete"])
|
||||
assert isinstance(ids, collections.Iterable)
|
||||
assert content_type, "content_type parameter is mandatory"
|
||||
assert content_type, "'content_type' parameter is mandatory"
|
||||
|
||||
app_name, model_name = content_type.split(".", 1)
|
||||
routing_key = "changes.project.{0}.{1}".format(projectid, app_name)
|
||||
|
|
|
@ -19,7 +19,7 @@ import codecs
|
|||
import uuid
|
||||
|
||||
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.models import signals
|
||||
from django.conf import settings
|
||||
|
|
|
@ -14,6 +14,8 @@
|
|||
# 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.utils.translation import ugettext as _
|
||||
|
||||
from taiga.projects.models import Membership
|
||||
|
||||
from . import serializers
|
||||
|
@ -27,7 +29,7 @@ class TaigaImportError(Exception):
|
|||
|
||||
def store_milestones(project, data):
|
||||
results = []
|
||||
for milestone_data in data.get('milestones', []):
|
||||
for milestone_data in data.get("milestones", []):
|
||||
milestone = service.store_milestone(project, milestone_data)
|
||||
results.append(milestone)
|
||||
return results
|
||||
|
@ -35,7 +37,7 @@ def store_milestones(project, data):
|
|||
|
||||
def store_tasks(project, data):
|
||||
results = []
|
||||
for task in data.get('tasks', []):
|
||||
for task in data.get("tasks", []):
|
||||
task = service.store_task(project, task)
|
||||
results.append(task)
|
||||
return results
|
||||
|
@ -43,29 +45,37 @@ def store_tasks(project, data):
|
|||
|
||||
def store_wiki_pages(project, data):
|
||||
results = []
|
||||
for wiki_page in data.get('wiki_pages', []):
|
||||
for wiki_page in data.get("wiki_pages", []):
|
||||
results.append(service.store_wiki_page(project, wiki_page))
|
||||
return results
|
||||
|
||||
|
||||
def store_wiki_links(project, data):
|
||||
results = []
|
||||
for wiki_link in data.get('wiki_links', []):
|
||||
for wiki_link in data.get("wiki_links", []):
|
||||
results.append(service.store_wiki_link(project, wiki_link))
|
||||
return results
|
||||
|
||||
|
||||
def store_user_stories(project, data):
|
||||
results = []
|
||||
for userstory in data.get('user_stories', []):
|
||||
for userstory in data.get("user_stories", []):
|
||||
us = service.store_user_story(project, userstory)
|
||||
results.append(us)
|
||||
return results
|
||||
|
||||
|
||||
def store_timeline_entries(project, data):
|
||||
results = []
|
||||
for timeline in data.get("timeline", []):
|
||||
tl = service.store_timeline_entry(project, timeline)
|
||||
results.append(tl)
|
||||
return results
|
||||
|
||||
|
||||
def store_issues(project, data):
|
||||
issues = []
|
||||
for issue in data.get('issues', []):
|
||||
for issue in data.get("issues", []):
|
||||
issues.append(service.store_issue(project, issue))
|
||||
return issues
|
||||
|
||||
|
@ -78,12 +88,12 @@ def store_tags_colors(project, data):
|
|||
|
||||
def dict_to_project(data, owner=None):
|
||||
if owner:
|
||||
data['owner'] = owner
|
||||
data["owner"] = owner
|
||||
|
||||
project_serialized = service.store_project(data)
|
||||
|
||||
if not project_serialized:
|
||||
raise TaigaImportError('error importing project')
|
||||
raise TaigaImportError(_("error importing project data"))
|
||||
|
||||
proj = project_serialized.object
|
||||
|
||||
|
@ -96,12 +106,12 @@ def dict_to_project(data, owner=None):
|
|||
service.store_choices(proj, data, "severities", serializers.SeverityExportSerializer)
|
||||
|
||||
if service.get_errors(clear=False):
|
||||
raise TaigaImportError('error importing choices')
|
||||
raise TaigaImportError(_("error importing lists of project attributes"))
|
||||
|
||||
service.store_default_choices(proj, data)
|
||||
|
||||
if service.get_errors(clear=False):
|
||||
raise TaigaImportError('error importing default choices')
|
||||
raise TaigaImportError(_("error importing default project attributes values"))
|
||||
|
||||
service.store_custom_attributes(proj, data, "userstorycustomattributes",
|
||||
serializers.UserStoryCustomAttributeExportSerializer)
|
||||
|
@ -111,12 +121,12 @@ def dict_to_project(data, owner=None):
|
|||
serializers.IssueCustomAttributeExportSerializer)
|
||||
|
||||
if service.get_errors(clear=False):
|
||||
raise TaigaImportError('error importing custom attributes')
|
||||
raise TaigaImportError(_("error importing custom attributes"))
|
||||
|
||||
service.store_roles(proj, data)
|
||||
|
||||
if service.get_errors(clear=False):
|
||||
raise TaigaImportError('error importing roles')
|
||||
raise TaigaImportError(_("error importing roles"))
|
||||
|
||||
service.store_memberships(proj, data)
|
||||
|
||||
|
@ -131,38 +141,45 @@ def dict_to_project(data, owner=None):
|
|||
)
|
||||
|
||||
if service.get_errors(clear=False):
|
||||
raise TaigaImportError('error importing memberships')
|
||||
raise TaigaImportError(_("error importing memberships"))
|
||||
|
||||
store_milestones(proj, data)
|
||||
|
||||
if service.get_errors(clear=False):
|
||||
raise TaigaImportError('error importing milestones')
|
||||
raise TaigaImportError(_("error importing sprints"))
|
||||
|
||||
store_wiki_pages(proj, data)
|
||||
|
||||
if service.get_errors(clear=False):
|
||||
raise TaigaImportError('error importing wiki pages')
|
||||
raise TaigaImportError(_("error importing wiki pages"))
|
||||
|
||||
store_wiki_links(proj, data)
|
||||
|
||||
if service.get_errors(clear=False):
|
||||
raise TaigaImportError('error importing wiki links')
|
||||
raise TaigaImportError(_("error importing wiki links"))
|
||||
|
||||
store_issues(proj, data)
|
||||
|
||||
if service.get_errors(clear=False):
|
||||
raise TaigaImportError('error importing issues')
|
||||
raise TaigaImportError(_("error importing issues"))
|
||||
|
||||
store_user_stories(proj, data)
|
||||
|
||||
if service.get_errors(clear=False):
|
||||
raise TaigaImportError('error importing user stories')
|
||||
raise TaigaImportError(_("error importing user stories"))
|
||||
|
||||
store_tasks(proj, data)
|
||||
|
||||
if service.get_errors(clear=False):
|
||||
raise TaigaImportError('error importing issues')
|
||||
raise TaigaImportError(_("error importing tasks"))
|
||||
|
||||
store_tags_colors(proj, data)
|
||||
|
||||
if service.get_errors(clear=False):
|
||||
raise TaigaImportError(_("error importing tags"))
|
||||
|
||||
store_timeline_entries(proj, data)
|
||||
if service.get_errors(clear=False):
|
||||
raise TaigaImportError(_("error importing timelines"))
|
||||
|
||||
return proj
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
# 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 rest_framework.renderers import UnicodeJSONRenderer
|
||||
from taiga.base.api.renderers import UnicodeJSONRenderer
|
||||
|
||||
|
||||
class ExportRenderer(UnicodeJSONRenderer):
|
||||
|
|
|
@ -15,16 +15,21 @@
|
|||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import base64
|
||||
import copy
|
||||
import os
|
||||
from collections import OrderedDict
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.files.base import ContentFile
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.core.exceptions import ValidationError
|
||||
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.custom_attributes import models as custom_attributes_models
|
||||
|
@ -35,11 +40,11 @@ from taiga.projects.milestones import models as milestones_models
|
|||
from taiga.projects.wiki import models as wiki_models
|
||||
from taiga.projects.history import models as history_models
|
||||
from taiga.projects.attachments import models as attachments_models
|
||||
from taiga.timeline import models as timeline_models
|
||||
from taiga.timeline import service as timeline_service
|
||||
from taiga.users import models as users_models
|
||||
from taiga.projects.votes import services as votes_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):
|
||||
|
@ -153,7 +158,7 @@ class ProjectRelatedField(serializers.RelatedField):
|
|||
kwargs = {self.slug_field: data, "project": self.context['project']}
|
||||
return self.queryset.get(**kwargs)
|
||||
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):
|
||||
|
@ -425,7 +430,7 @@ class MembershipExportSerializer(serializers.ModelSerializer):
|
|||
|
||||
class Meta:
|
||||
model = projects_models.Membership
|
||||
exclude = ('id', 'project')
|
||||
exclude = ('id', 'project', 'token')
|
||||
|
||||
def full_clean(self, instance):
|
||||
return instance
|
||||
|
@ -458,7 +463,7 @@ class MilestoneExportSerializer(serializers.ModelSerializer):
|
|||
name = attrs[source]
|
||||
qs = self.project.milestones.filter(name=name)
|
||||
if qs.exists():
|
||||
raise serializers.ValidationError("Name duplicated for the project")
|
||||
raise serializers.ValidationError(_("Name duplicated for the project"))
|
||||
|
||||
return attrs
|
||||
|
||||
|
@ -546,6 +551,39 @@ class WikiLinkExportSerializer(serializers.ModelSerializer):
|
|||
exclude = ('id', 'project')
|
||||
|
||||
|
||||
|
||||
class TimelineDataField(serializers.WritableField):
|
||||
read_only = False
|
||||
|
||||
def to_native(self, data):
|
||||
new_data = copy.deepcopy(data)
|
||||
try:
|
||||
user = users_models.User.objects.get(pk=new_data["user"]["id"])
|
||||
new_data["user"]["email"] = user.email
|
||||
del new_data["user"]["id"]
|
||||
except users_models.User.DoesNotExist:
|
||||
pass
|
||||
return new_data
|
||||
|
||||
def from_native(self, data):
|
||||
new_data = copy.deepcopy(data)
|
||||
try:
|
||||
user = users_models.User.objects.get(email=new_data["user"]["email"])
|
||||
new_data["user"]["id"] = user.id
|
||||
del new_data["user"]["email"]
|
||||
except users_models.User.DoesNotExist:
|
||||
pass
|
||||
|
||||
return new_data
|
||||
|
||||
|
||||
class TimelineExportSerializer(serializers.ModelSerializer):
|
||||
data = TimelineDataField()
|
||||
class Meta:
|
||||
model = timeline_models.Timeline
|
||||
exclude = ('id', 'project', 'namespace', 'object_id')
|
||||
|
||||
|
||||
class ProjectExportSerializer(serializers.ModelSerializer):
|
||||
owner = UserRelatedField(required=False)
|
||||
default_points = serializers.SlugRelatedField(slug_field="name", required=False)
|
||||
|
@ -577,7 +615,12 @@ class ProjectExportSerializer(serializers.ModelSerializer):
|
|||
anon_permissions = PgArrayField(required=False)
|
||||
public_permissions = PgArrayField(required=False)
|
||||
modified_date = serializers.DateTimeField(required=False)
|
||||
timeline = serializers.SerializerMethodField("get_timeline")
|
||||
|
||||
class Meta:
|
||||
model = projects_models.Project
|
||||
exclude = ('id', 'creation_template', 'members')
|
||||
|
||||
def get_timeline(self, obj):
|
||||
timeline_qs = timeline_service.get_project_timeline(obj)
|
||||
return TimelineExportSerializer(timeline_qs, many=True).data
|
||||
|
|
|
@ -23,6 +23,7 @@ from django.contrib.contenttypes.models import ContentType
|
|||
from django.core.exceptions import ObjectDoesNotExist
|
||||
|
||||
from taiga.projects.history.services import make_key_from_model_object
|
||||
from taiga.timeline.service import build_project_namespace
|
||||
from taiga.projects.references import sequences as seq
|
||||
from taiga.projects.references import models as refs
|
||||
from taiga.projects.services import find_invited_user
|
||||
|
@ -182,8 +183,7 @@ def store_membership(project, membership):
|
|||
if serialized.is_valid():
|
||||
serialized.object.project = project
|
||||
serialized.object._importing = True
|
||||
if not serialized.object.token:
|
||||
serialized.object.token = str(uuid.uuid1())
|
||||
serialized.object.token = str(uuid.uuid1())
|
||||
serialized.object.user = find_invited_user(serialized.object.email,
|
||||
default=serialized.object.user)
|
||||
serialized.save()
|
||||
|
@ -276,6 +276,19 @@ def store_attachment(project, obj, attachment):
|
|||
return serialized
|
||||
|
||||
|
||||
def store_timeline_entry(project, timeline):
|
||||
serialized = serializers.TimelineExportSerializer(data=timeline, context={"project": project})
|
||||
if serialized.is_valid():
|
||||
serialized.object.project = project
|
||||
serialized.object.namespace = build_project_namespace(project)
|
||||
serialized.object.object_id = project.id
|
||||
serialized.object._importing = True
|
||||
serialized.save()
|
||||
return serialized
|
||||
add_errors("timeline", serialized.errors)
|
||||
return serialized
|
||||
|
||||
|
||||
def store_history(project, obj, history):
|
||||
serialized = serializers.HistoryExportSerializer(data=history, context={"project": project})
|
||||
if serialized.is_valid():
|
||||
|
|
|
@ -20,6 +20,7 @@ from django.core.files.storage import default_storage
|
|||
from django.core.files.base import ContentFile
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from djmail.template_mail import MagicMailBuilder, InlineCSSTemplateMail
|
||||
|
||||
|
@ -45,11 +46,11 @@ def dump_project(self, user, project):
|
|||
except Exception:
|
||||
ctx = {
|
||||
"user": user,
|
||||
"error_subject": "Error generating project dump",
|
||||
"error_message": "Error generating project dump",
|
||||
"error_subject": _("Error generating project dump"),
|
||||
"error_message": _("Error generating project dump"),
|
||||
"project": project
|
||||
}
|
||||
email = mbuilder.export_error(user.email, ctx)
|
||||
email = mbuilder.export_error(user, ctx)
|
||||
email.send()
|
||||
return
|
||||
|
||||
|
@ -60,7 +61,7 @@ def dump_project(self, user, project):
|
|||
"user": user,
|
||||
"deletion_date": deletion_date
|
||||
}
|
||||
email = mbuilder.dump_project(user.email, ctx)
|
||||
email = mbuilder.dump_project(user, ctx)
|
||||
email.send()
|
||||
|
||||
|
||||
|
@ -78,13 +79,13 @@ def load_project_dump(user, dump):
|
|||
except Exception:
|
||||
ctx = {
|
||||
"user": user,
|
||||
"error_subject": "Error loading project dump",
|
||||
"error_message": "Error loading project dump",
|
||||
"error_subject": _("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()
|
||||
return
|
||||
|
||||
ctx = {"user": user, "project": project}
|
||||
email = mbuilder.load_dump(user.email, ctx)
|
||||
email = mbuilder.load_dump(user, ctx)
|
||||
email.send()
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
{% extends "emails/base-body-html.jinja" %}
|
||||
|
||||
{% block body %}
|
||||
{% trans user=user.get_full_name()|safe,
|
||||
project=project.name|safe,
|
||||
url=url,
|
||||
deletion_date=deletion_date|date("SHORT_DATETIME_FORMAT") + deletion_date|date(" T") %}
|
||||
{% trans user=user.get_full_name()|safe, project=project.name|safe, url=url, deletion_date=deletion_date|date("SHORT_DATETIME_FORMAT") + deletion_date|date(" T") %}
|
||||
<h1>Project dump generated</h1>
|
||||
<p>Hello {{ user }},</p>
|
||||
<h3>Your dump from project {{ project }} has been correctly generated.</h3>
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
{% trans user=user.get_full_name()|safe,
|
||||
project=project.name|safe,
|
||||
url=url,
|
||||
deletion_date=deletion_date|date("SHORT_DATETIME_FORMAT") + deletion_date|date(" T") %}
|
||||
{% trans user=user.get_full_name()|safe, project=project.name|safe, url=url, deletion_date=deletion_date|date("SHORT_DATETIME_FORMAT") + deletion_date|date(" T") %}
|
||||
Hello {{ user }},
|
||||
|
||||
Your dump from project {{ project }} has been correctly generated. You can download it here:
|
||||
|
|
|
@ -1,10 +1,7 @@
|
|||
{% extends "emails/base-body-html.jinja" %}
|
||||
|
||||
{% block body %}
|
||||
{% trans user=user.get_full_name()|safe,
|
||||
error_message=error_message,
|
||||
support_email=sr("support.email"),
|
||||
project=project.name|safe %}
|
||||
{% trans user=user.get_full_name()|safe, error_message=error_message, support_email=sr("support.email"), project=project.name|safe %}
|
||||
<h1>{{ error_message }}</h1>
|
||||
<p>Hello {{ user }},</p>
|
||||
<p>Your project {{ project }} has not been exported correctly.</p>
|
||||
|
|
|
@ -1,7 +1,4 @@
|
|||
{% trans user=user.get_full_name()|safe,
|
||||
error_message=error_message,
|
||||
support_email=sr("support.email"),
|
||||
project=project.name|safe %}
|
||||
{% trans user=user.get_full_name()|safe, error_message=error_message, support_email=sr("support.email"), project=project.name|safe %}
|
||||
Hello {{ user }},
|
||||
|
||||
{{ error_message }}
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
{% extends "emails/base-body-html.jinja" %}
|
||||
|
||||
{% block body %}
|
||||
{% trans user=user.get_full_name()|safe,
|
||||
error_message=error_message,
|
||||
support_email=sr("support.email") %}
|
||||
{% trans user=user.get_full_name()|safe, error_message=error_message, support_email=sr("support.email") %}
|
||||
<h1>{{ error_message }}</h1>
|
||||
<p>Hello {{ user }},</p>
|
||||
<p>Your project has not been importer correctly.</p>
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
{% trans user=user.get_full_name()|safe,
|
||||
error_message=error_message,
|
||||
support_email=sr("support.email") %}
|
||||
{% trans user=user.get_full_name()|safe, error_message=error_message, support_email=sr("support.email") %}
|
||||
Hello {{ user }},
|
||||
|
||||
{{ error_message }}
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
{% extends "emails/base-body-html.jinja" %}
|
||||
|
||||
{% block body %}
|
||||
{% trans user=user.get_full_name()|safe,
|
||||
url=resolve_front_url("project", project.slug),
|
||||
project=project.name|safe %}
|
||||
{% trans user=user.get_full_name()|safe, url=resolve_front_url("project", project.slug), project=project.name|safe %}
|
||||
<h1>Project dump imported</h1>
|
||||
<p>Hello {{ user }},</p>
|
||||
<h3>Your project dump has been correctly imported.</h3>
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
{% trans user=user.get_full_name()|safe,
|
||||
url=resolve_front_url("project", project.slug),
|
||||
project=project.name|safe %}
|
||||
{% trans user=user.get_full_name()|safe, url=resolve_front_url("project", project.slug), project=project.name|safe %}
|
||||
Hello {{ user }},
|
||||
|
||||
Your project dump has been correctly imported.
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
# 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 rest_framework import serializers
|
||||
from taiga.base.api import serializers
|
||||
|
||||
from . import models
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
# 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.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from taiga.base import exceptions as exc
|
||||
from taiga.base import response
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
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.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.hooks.event_hooks import BaseEventHook
|
||||
from taiga.hooks.exceptions import ActionSyntaxException
|
||||
from taiga.base.utils import json
|
||||
|
||||
from .services import get_bitbucket_user
|
||||
|
||||
import json
|
||||
|
||||
|
||||
class PushEventHook(BaseEventHook):
|
||||
def process_event(self):
|
||||
|
@ -92,7 +91,7 @@ class PushEventHook(BaseEventHook):
|
|||
element.save()
|
||||
|
||||
snapshot = take_snapshot(element,
|
||||
comment="Status changed from BitBucket commit",
|
||||
comment=_("Status changed from BitBucket commit"),
|
||||
user=get_bitbucket_user(bitbucket_user))
|
||||
send_notifications(element, history=snapshot)
|
||||
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
# 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.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import ugettext as _
|
||||
|
||||
from taiga.projects.models import IssueStatus, TaskStatus, UserStoryStatus
|
||||
|
||||
|
@ -36,14 +36,13 @@ class PushEventHook(BaseEventHook):
|
|||
if self.payload is None:
|
||||
return
|
||||
|
||||
github_user = self.payload.get('sender', {}).get('id', None)
|
||||
github_user = self.payload.get('sender', {})
|
||||
|
||||
commits = self.payload.get("commits", [])
|
||||
for commit in commits:
|
||||
message = commit.get("message", None)
|
||||
self._process_message(message, github_user)
|
||||
self._process_commit(commit, github_user)
|
||||
|
||||
def _process_message(self, message, github_user):
|
||||
def _process_commit(self, commit, github_user):
|
||||
"""
|
||||
The message we will be looking for seems like
|
||||
TG-XX #yyyyyy
|
||||
|
@ -51,6 +50,8 @@ class PushEventHook(BaseEventHook):
|
|||
XX: is the ref for us, issue or task
|
||||
yyyyyy: is the status slug we are setting
|
||||
"""
|
||||
message = commit.get("message", None)
|
||||
|
||||
if message is None:
|
||||
return
|
||||
|
||||
|
@ -59,9 +60,9 @@ class PushEventHook(BaseEventHook):
|
|||
if m:
|
||||
ref = m.group(1)
|
||||
status_slug = m.group(2)
|
||||
self._change_status(ref, status_slug, github_user)
|
||||
self._change_status(ref, status_slug, github_user, commit)
|
||||
|
||||
def _change_status(self, ref, status_slug, github_user):
|
||||
def _change_status(self, ref, status_slug, github_user, commit):
|
||||
if Issue.objects.filter(project=self.project, ref=ref).exists():
|
||||
modelClass = Issue
|
||||
statusClass = IssueStatus
|
||||
|
@ -84,9 +85,31 @@ class PushEventHook(BaseEventHook):
|
|||
element.status = status
|
||||
element.save()
|
||||
|
||||
github_user_id = github_user.get('id', None)
|
||||
github_user_name = github_user.get('login', None)
|
||||
github_user_url = github_user.get('html_url', None)
|
||||
commit_id = commit.get("id", None)
|
||||
commit_url = commit.get("url", None)
|
||||
commit_message = commit.get("message", None)
|
||||
|
||||
if (github_user_id and github_user_name and github_user_url and
|
||||
commit_id and commit_url and commit_message):
|
||||
comment = _("Status changed by [@{github_user_name}]({github_user_url} "
|
||||
"\"See @{github_user_name}'s GitHub profile\") "
|
||||
"from GitHub commit [{commit_id}]({commit_url} "
|
||||
"\"See commit '{commit_id} - {commit_message}'\").").format(
|
||||
github_user_name=github_user_name,
|
||||
github_user_url=github_user_url,
|
||||
commit_id=commit_id[:7],
|
||||
commit_url=commit_url,
|
||||
commit_message=commit_message)
|
||||
|
||||
else:
|
||||
comment = _("Status changed from GitHub commit.")
|
||||
|
||||
snapshot = take_snapshot(element,
|
||||
comment="Status changed from GitHub commit",
|
||||
user=get_github_user(github_user))
|
||||
comment=comment,
|
||||
user=get_github_user(github_user_id))
|
||||
send_notifications(element, history=snapshot)
|
||||
|
||||
|
||||
|
@ -103,11 +126,17 @@ class IssuesEventHook(BaseEventHook):
|
|||
if self.payload.get('action', None) != "opened":
|
||||
return
|
||||
|
||||
number = self.payload.get('issue', {}).get('number', None)
|
||||
subject = self.payload.get('issue', {}).get('title', None)
|
||||
description = self.payload.get('issue', {}).get('body', None)
|
||||
github_url = self.payload.get('issue', {}).get('html_url', None)
|
||||
github_user = self.payload.get('issue', {}).get('user', {}).get('id', None)
|
||||
github_user_id = self.payload.get('issue', {}).get('user', {}).get('id', None)
|
||||
github_user_name = self.payload.get('issue', {}).get('user', {}).get('login', None)
|
||||
github_user_url = self.payload.get('issue', {}).get('user', {}).get('html_url', None)
|
||||
project_url = self.payload.get('repository', {}).get('html_url', None)
|
||||
description = self.payload.get('issue', {}).get('body', None)
|
||||
description = replace_github_references(project_url, description)
|
||||
|
||||
user = get_github_user(github_user_id)
|
||||
|
||||
if not all([subject, github_url, project_url]):
|
||||
raise ActionSyntaxException(_("Invalid issue information"))
|
||||
|
@ -115,17 +144,31 @@ class IssuesEventHook(BaseEventHook):
|
|||
issue = Issue.objects.create(
|
||||
project=self.project,
|
||||
subject=subject,
|
||||
description=replace_github_references(project_url, description),
|
||||
description=description,
|
||||
status=self.project.default_issue_status,
|
||||
type=self.project.default_issue_type,
|
||||
severity=self.project.default_severity,
|
||||
priority=self.project.default_priority,
|
||||
external_reference=['github', github_url],
|
||||
owner=get_github_user(github_user)
|
||||
owner=user
|
||||
)
|
||||
take_snapshot(issue, user=get_github_user(github_user))
|
||||
take_snapshot(issue, user=user)
|
||||
|
||||
snapshot = take_snapshot(issue, comment="Created from GitHub", user=get_github_user(github_user))
|
||||
if number and subject and github_user_name and github_user_url:
|
||||
comment = _("Issue created by [@{github_user_name}]({github_user_url} "
|
||||
"\"See @{github_user_name}'s GitHub profile\") "
|
||||
"from GitHub.\nOrigin GitHub issue: [gh#{number} - {subject}]({github_url} "
|
||||
"\"Go to 'gh#{number} - {subject}'\"):\n\n"
|
||||
"{description}").format(github_user_name=github_user_name,
|
||||
github_user_url=github_user_url,
|
||||
number=number,
|
||||
subject=subject,
|
||||
github_url=github_url,
|
||||
description=description)
|
||||
else:
|
||||
comment = _("Issue created from GitHub.")
|
||||
|
||||
snapshot = take_snapshot(issue, comment=comment, user=user)
|
||||
send_notifications(issue, history=snapshot)
|
||||
|
||||
|
||||
|
@ -134,12 +177,18 @@ class IssueCommentEventHook(BaseEventHook):
|
|||
if self.payload.get('action', None) != "created":
|
||||
raise ActionSyntaxException(_("Invalid issue comment information"))
|
||||
|
||||
number = self.payload.get('issue', {}).get('number', None)
|
||||
subject = self.payload.get('issue', {}).get('title', None)
|
||||
github_url = self.payload.get('issue', {}).get('html_url', None)
|
||||
comment_message = self.payload.get('comment', {}).get('body', None)
|
||||
github_user = self.payload.get('sender', {}).get('id', None)
|
||||
github_user_id = self.payload.get('sender', {}).get('id', None)
|
||||
github_user_name = self.payload.get('sender', {}).get('login', None)
|
||||
github_user_url = self.payload.get('sender', {}).get('html_url', None)
|
||||
project_url = self.payload.get('repository', {}).get('html_url', None)
|
||||
comment_message = self.payload.get('comment', {}).get('body', None)
|
||||
comment_message = replace_github_references(project_url, comment_message)
|
||||
|
||||
user = get_github_user(github_user_id)
|
||||
|
||||
if not all([comment_message, github_url, project_url]):
|
||||
raise ActionSyntaxException(_("Invalid issue comment information"))
|
||||
|
||||
|
@ -148,7 +197,19 @@ class IssueCommentEventHook(BaseEventHook):
|
|||
uss = UserStory.objects.filter(external_reference=["github", github_url])
|
||||
|
||||
for item in list(issues) + list(tasks) + list(uss):
|
||||
snapshot = take_snapshot(item,
|
||||
comment="From GitHub:\n\n{}".format(comment_message),
|
||||
user=get_github_user(github_user))
|
||||
if number and subject and github_user_name and github_user_url:
|
||||
comment = _("Comment by [@{github_user_name}]({github_user_url} "
|
||||
"\"See @{github_user_name}'s GitHub profile\") "
|
||||
"from GitHub.\nOrigin GitHub issue: [gh#{number} - {subject}]({github_url} "
|
||||
"\"Go to 'gh#{number} - {subject}'\")\n\n"
|
||||
"{message}").format(github_user_name=github_user_name,
|
||||
github_user_url=github_user_url,
|
||||
number=number,
|
||||
subject=subject,
|
||||
github_url=github_url,
|
||||
message=comment_message)
|
||||
else:
|
||||
comment = _("Comment From GitHub:\n\n{message}").format(message=comment_message)
|
||||
|
||||
snapshot = take_snapshot(item, comment=comment, user=user)
|
||||
send_notifications(item, history=snapshot)
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
import re
|
||||
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
|
||||
|
||||
|
@ -84,7 +84,7 @@ class PushEventHook(BaseEventHook):
|
|||
element.save()
|
||||
|
||||
snapshot = take_snapshot(element,
|
||||
comment="Status changed from GitLab commit",
|
||||
comment=_("Status changed from GitLab commit"),
|
||||
user=get_gitlab_user(gitlab_user))
|
||||
send_notifications(element, history=snapshot)
|
||||
|
||||
|
@ -126,5 +126,5 @@ class IssuesEventHook(BaseEventHook):
|
|||
)
|
||||
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)
|
||||
|
|
|
@ -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, "bidi": c in settings.LANGUAGES_BIDI} 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
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue